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
|
||||
548
blender-5.2.0/tests/performance/benchmark.py
Executable file
548
blender-5.2.0/tests/performance/benchmark.py
Executable file
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""
|
||||
The main entry point to running benchmark tests.
|
||||
|
||||
See https://developer.blender.org/docs/handbook/testing/performance/
|
||||
for a general introduction to the topic.
|
||||
"""
|
||||
|
||||
import api
|
||||
import argparse
|
||||
import fnmatch
|
||||
import glob
|
||||
import logging
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def find_blender_git_dir() -> pathlib.Path:
|
||||
# Find .git directory of the repository we are in.
|
||||
cwd = pathlib.Path.cwd()
|
||||
|
||||
for path in [cwd] + list(cwd.parents):
|
||||
if (path / '.git').exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_tests_base_dir(blender_git_dir: pathlib.Path) -> pathlib.Path:
|
||||
# Benchmarks dir is next to the Blender source folder.
|
||||
return blender_git_dir.parent / 'benchmark'
|
||||
|
||||
|
||||
def use_revision_columns(config: api.TestConfig) -> bool:
|
||||
return (
|
||||
config.benchmark_type == "comparison" and
|
||||
len(config.queue.entries) > 0
|
||||
)
|
||||
|
||||
|
||||
def init_table(config: api.TestConfig) -> api.MarkdownTable:
|
||||
table = api.MarkdownTable()
|
||||
table.add_column("Revision")
|
||||
table.add_column("Category", is_visible=config.queue.has_multiple_categories)
|
||||
table.add_column("Device", is_visible=config.queue.has_multiple_devices)
|
||||
table.add_column("Test", width=40)
|
||||
if use_revision_columns(config):
|
||||
for revision_name in config.revision_names():
|
||||
table.add_column(revision_name, width=20, alignment='RIGHT')
|
||||
table.columns[0].is_visible = False
|
||||
else:
|
||||
table.add_column("Result", width=20, alignment='RIGHT')
|
||||
return table
|
||||
|
||||
|
||||
def print_row(table: api.MarkdownTable, entries: list, end='\n') -> None:
|
||||
# Print one or more test entries on a row.
|
||||
row = []
|
||||
|
||||
# For time series, revision is printed first.
|
||||
row.append(entries[0].revision)
|
||||
row.append(entries[0].category)
|
||||
row.append(api.normalize_device_id(entries[0].device_id))
|
||||
row.append(entries[0].test)
|
||||
|
||||
for entry in entries:
|
||||
# Show time or status.
|
||||
status = entry.status
|
||||
output = entry.output
|
||||
result = ''
|
||||
if status in {'done', 'outdated'} and output:
|
||||
if 'time' in output:
|
||||
result = '%7.4f s' % output['time']
|
||||
elif 'fps' in output:
|
||||
result = '%8.3f fps' % output['fps']
|
||||
|
||||
if status == 'outdated':
|
||||
result += " (outdated)"
|
||||
elif status == 'failed':
|
||||
result = "failed: " + entry.error_msg
|
||||
else:
|
||||
result = status
|
||||
row.append(result)
|
||||
|
||||
table.print_row(row, end=end)
|
||||
|
||||
|
||||
def print_entry(table: api.MarkdownTable, entry: api.TestEntry) -> None:
|
||||
# Print a single test entry, potentially on multiple lines, with more details than in `print_row`.
|
||||
# NOTE: Currently only used to print detailed error info.
|
||||
|
||||
print_row(table, [entry])
|
||||
|
||||
if entry.status != 'failed':
|
||||
return
|
||||
if not entry.exception_msg:
|
||||
return
|
||||
print(entry.exception_msg, flush=True)
|
||||
|
||||
|
||||
def match_entry(entry: api.TestEntry, args: argparse.Namespace):
|
||||
# Filter tests by name and category.
|
||||
return (
|
||||
fnmatch.fnmatch(entry.test, args.test) or
|
||||
fnmatch.fnmatch(entry.category, args.test) or
|
||||
entry.test.find(args.test) != -1 or
|
||||
entry.category.find(args.test) != -1
|
||||
)
|
||||
|
||||
|
||||
def run_entry(env: api.TestEnvironment,
|
||||
config: api.TestConfig,
|
||||
table: api.MarkdownTable,
|
||||
row: list,
|
||||
entry: api.TestEntry,
|
||||
update_only: bool,
|
||||
count: int,
|
||||
update_submodules: bool = True):
|
||||
updated = False
|
||||
failed = False
|
||||
|
||||
# Check if entry needs to be run.
|
||||
if update_only and entry.status not in {'queued', 'outdated'}:
|
||||
print_row(table, row, end='\r')
|
||||
return updated, failed
|
||||
|
||||
# Run test entry.
|
||||
revision = entry.revision
|
||||
git_hash = entry.git_hash
|
||||
environment = entry.environment
|
||||
testname = entry.test
|
||||
testcategory = entry.category
|
||||
device_type = entry.device_type
|
||||
device_id = entry.device_id
|
||||
|
||||
gpu_backend = {
|
||||
'VULKAN': 'vulkan',
|
||||
'METAL': 'metal',
|
||||
'OPENGL': 'opengl'
|
||||
}.get(device_type, 'default')
|
||||
|
||||
test = config.tests.find(testname, testcategory)
|
||||
if not test:
|
||||
return updated, failed
|
||||
|
||||
updated = True
|
||||
|
||||
# Log all output to dedicated log file.
|
||||
logname = testcategory + '_' + testname + '_' + device_id + '_' + revision
|
||||
env.set_log_file(config.logs_dir / (logname + '.log'), clear=True)
|
||||
|
||||
# Clear output
|
||||
entry.output = None
|
||||
entry.error_msg = ''
|
||||
|
||||
# Build revision, or just set path to existing executable.
|
||||
executable_ok = True
|
||||
if len(entry.executable):
|
||||
env.set_blender_executable(pathlib.Path(entry.executable), environment)
|
||||
else:
|
||||
entry.status = 'building'
|
||||
print_row(table, row, end='\r')
|
||||
|
||||
if config.benchmark_type == "comparison":
|
||||
install_dir = config.builds_dir / revision
|
||||
else:
|
||||
install_dir = env.install_dir
|
||||
executable_ok = env.build(git_hash, install_dir, update_submodules)
|
||||
|
||||
if not executable_ok:
|
||||
entry.status = 'failed'
|
||||
entry.error_msg = 'Failed to build'
|
||||
failed = True
|
||||
else:
|
||||
env.set_blender_executable(install_dir, environment)
|
||||
|
||||
# Run test and update output and status.
|
||||
if executable_ok:
|
||||
run_outputs = []
|
||||
for run in range(count):
|
||||
entry.status = 'running' if count == 1 else f'run [{run + 1}/{count}]'
|
||||
print_row(table, row, end='\r')
|
||||
|
||||
try:
|
||||
output = test.run(env, device_id, gpu_backend)
|
||||
if not output:
|
||||
raise Exception("Test produced no output")
|
||||
run_outputs.append(output)
|
||||
entry.status = 'done'
|
||||
except KeyboardInterrupt as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
failed = True
|
||||
entry.status = 'failed'
|
||||
entry.error_msg = 'Failed to run'
|
||||
entry.exception_msg = str(e)
|
||||
break
|
||||
|
||||
if entry.status == 'done' and run_outputs:
|
||||
# Combine results from runs
|
||||
|
||||
keys = set()
|
||||
for run_output in run_outputs:
|
||||
keys |= run_output.keys()
|
||||
|
||||
output = {}
|
||||
output_all_runs = {}
|
||||
for key in keys:
|
||||
values = []
|
||||
for run_output in run_outputs:
|
||||
if key not in run_output:
|
||||
continue
|
||||
values.append(run_output[key])
|
||||
output[key] = sum(values) / len(values)
|
||||
output_all_runs[key] = values
|
||||
entry.output = output
|
||||
entry.output_all_runs = output_all_runs
|
||||
|
||||
print_row(table, row, end='\r')
|
||||
|
||||
# Update device name in case the device changed since the entry was created.
|
||||
entry.device_name = config.device_name(device_id)
|
||||
|
||||
# Restore default logging and Blender executable.
|
||||
env.unset_log_file()
|
||||
env.set_default_blender_executable()
|
||||
|
||||
return updated, failed
|
||||
|
||||
|
||||
def cmd_init(env: api.TestEnvironment, argv: list):
|
||||
# Initialize benchmarks folder.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--build', default=False, action='store_true')
|
||||
parser.add_argument('--blender')
|
||||
args = parser.parse_args(argv)
|
||||
env.set_log_file(env.base_dir / 'setup.log', clear=False)
|
||||
env.init(args.build, args.blender)
|
||||
env.unset_log_file()
|
||||
|
||||
|
||||
def cmd_list(env: api.TestEnvironment, argv: list) -> None:
|
||||
# List devices, tests and configurations.
|
||||
print('DEVICES')
|
||||
machine = env.get_machine()
|
||||
for device in machine.devices:
|
||||
name = f"{device.name} ({device.operating_system})"
|
||||
print(f"{device.id: <15} {name}")
|
||||
print('')
|
||||
|
||||
print('TESTS')
|
||||
collection = api.TestCollection(env)
|
||||
for test in collection.tests:
|
||||
print(f"{test.category(): <15} {test.name(): <50}")
|
||||
print('')
|
||||
|
||||
print('CONFIGS')
|
||||
configs = env.get_config_names()
|
||||
for config_name in configs:
|
||||
print(config_name)
|
||||
|
||||
|
||||
def cmd_status(env: api.TestEnvironment, argv: list):
|
||||
# Print status of tests in configurations.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('config', nargs='?', default=None)
|
||||
parser.add_argument('test', nargs='?', default='*')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
configs = env.get_configs(args.config)
|
||||
first = True
|
||||
for config in configs:
|
||||
if not args.config:
|
||||
if first:
|
||||
first = False
|
||||
else:
|
||||
print("")
|
||||
print(config.name.upper())
|
||||
|
||||
table = init_table(config)
|
||||
table.print_header()
|
||||
for row in config.queue.rows(use_revision_columns(config)):
|
||||
if match_entry(row[0], args):
|
||||
print_row(table, row)
|
||||
|
||||
|
||||
def cmd_reset(env: api.TestEnvironment, argv: list):
|
||||
# Reset tests to re-run them.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('config', nargs='?', default=None)
|
||||
parser.add_argument('test', nargs='?', default='*')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
configs = env.get_configs(args.config)
|
||||
for config in configs:
|
||||
table = init_table(config)
|
||||
table.print_header()
|
||||
for row in config.queue.rows(use_revision_columns(config)):
|
||||
if match_entry(row[0], args):
|
||||
for entry in row:
|
||||
entry.status = 'queued'
|
||||
entry.result = {}
|
||||
print_row(table, row)
|
||||
|
||||
config.queue.write()
|
||||
|
||||
if args.test == '*':
|
||||
shutil.rmtree(config.logs_dir)
|
||||
|
||||
|
||||
def cmd_run(env: api.TestEnvironment, argv: list, update_only: bool):
|
||||
# Run tests.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('config', nargs='?', default=None)
|
||||
parser.add_argument('test', nargs='?', default='*')
|
||||
parser.add_argument('--count', default=1, type=int, help="Number of runs to perform (default=1)")
|
||||
parser.add_argument(
|
||||
'--no-submodules',
|
||||
action='store_true',
|
||||
help="Skip updating submodules when checking out revisions. Useful when testing performance regressions for library changes.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
exit_code = 0
|
||||
|
||||
configs = env.get_configs(args.config)
|
||||
for config in configs:
|
||||
updated = False
|
||||
cancel = False
|
||||
table = init_table(config)
|
||||
table.print_header()
|
||||
for row in config.queue.rows(use_revision_columns(config)):
|
||||
if match_entry(row[0], args):
|
||||
for entry in row:
|
||||
try:
|
||||
test_updated, test_failed = run_entry(
|
||||
env, config, table, row, entry, update_only, args.count, not args.no_submodules)
|
||||
if test_updated:
|
||||
updated = True
|
||||
# Write queue every time in case running gets interrupted,
|
||||
# so it can be resumed.
|
||||
config.queue.write()
|
||||
if test_failed:
|
||||
exit_code = 1
|
||||
print_entry(table, entry)
|
||||
except KeyboardInterrupt as e:
|
||||
cancel = True
|
||||
break
|
||||
|
||||
print_row(table, row)
|
||||
|
||||
if cancel:
|
||||
break
|
||||
|
||||
if updated:
|
||||
# Generate graph if test were run.
|
||||
json_filepath = config.base_dir / "results.json"
|
||||
html_filepath = config.base_dir / "results.html"
|
||||
graph = api.TestGraph([json_filepath])
|
||||
graph.write(html_filepath)
|
||||
|
||||
print("\nfile://" + str(html_filepath))
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def cmd_bisect(env: api.TestEnvironment, argv: list):
|
||||
import datetime
|
||||
SECONDS_PER_DAY = 86400
|
||||
|
||||
parser = argparse.ArgumentParser(prog='benchmark.py bisect')
|
||||
parser.add_argument('--device', required=True,
|
||||
help='Device type or ID to run tests on')
|
||||
parser.add_argument('--category', required=True,
|
||||
help='Test category (e.g. eevee, cycles)')
|
||||
parser.add_argument('--test', required=True,
|
||||
help='Test name (supports glob patterns)')
|
||||
parser.add_argument('--attribute', required=True,
|
||||
help='Performance attribute to compare (e.g. fps, time)')
|
||||
parser.add_argument('--threshold', required=True, type=float,
|
||||
help='Threshold value for pass/fail decision')
|
||||
parser.add_argument('--success', required=True, choices=['greater_than', 'less_than'],
|
||||
help='Whether higher or lower values are considered a success')
|
||||
parser.add_argument('--range', required=True,
|
||||
help='Date range in YYYYMMDD-YYYYMMDD format')
|
||||
parser.add_argument('--count', default=1, type=int,
|
||||
help='Number of benchmark runs per commit (default=1)')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not env.build_dir.exists() or not env.blender_dir.exists():
|
||||
sys.stderr.write('Error: benchmark build not initialized. Run "benchmark.py init --build" first.\n')
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
start_str, end_str = args.range.split('-')
|
||||
start_dt = datetime.datetime.strptime(start_str, '%Y%m%d').replace(tzinfo=datetime.timezone.utc)
|
||||
end_dt = datetime.datetime.strptime(end_str, '%Y%m%d').replace(tzinfo=datetime.timezone.utc)
|
||||
except:
|
||||
sys.stderr.write('Error: invalid date range format. Use YYYYMMDD-YYYYMMDD\n')
|
||||
sys.exit(1)
|
||||
if start_dt >= end_dt:
|
||||
sys.stderr.write(f'Error: invalid date range {start_str} must be before {end_str}\n')
|
||||
sys.exit(1)
|
||||
|
||||
collection = api.TestCollection(env, [args.test], [args.category])
|
||||
test = collection.find(args.test, args.category)
|
||||
if not test:
|
||||
sys.stderr.write(f'Error: test not found: {args.category}/{args.test}\n')
|
||||
sys.exit(1)
|
||||
|
||||
device_id, gpu_backend = env.resolve_device(args.device)
|
||||
|
||||
print(f"Device: {args.device}")
|
||||
print(f"Category: {args.category}")
|
||||
print(f"Test: {args.test}")
|
||||
print()
|
||||
|
||||
table = api.MarkdownTable()
|
||||
table.add_column("Remaining", width=5, alignment='RIGHT')
|
||||
table.add_column("Commit", width=14)
|
||||
table.add_column("Date (UTC)", width=22)
|
||||
table.add_column("Title", width=72)
|
||||
table.add_column(args.attribute, width=14, alignment='RIGHT')
|
||||
table.add_column("Status", width=8)
|
||||
table.print_header()
|
||||
|
||||
tested = set()
|
||||
|
||||
def print_status(row_values, end='\n'):
|
||||
table.print_row([str(progress.remaining)] + row_values, end=end)
|
||||
|
||||
def run_commit_wrapper(commit_hash, commit_ts):
|
||||
return api.Bisect.run_commit(
|
||||
env, test, device_id, gpu_backend, args.count, args.attribute,
|
||||
args.success, args.threshold, tested,
|
||||
print_status, commit_hash, commit_ts)
|
||||
|
||||
# Phase 1: Daily scan
|
||||
start_ts = int(start_dt.timestamp())
|
||||
end_ts = int(end_dt.timestamp()) + SECONDS_PER_DAY
|
||||
|
||||
progress = api.bisect.BisectProgress()
|
||||
env.set_log_file(env.base_dir / 'bisect.log', clear=True)
|
||||
bisect = api.bisect.Bisect(env, run_commit_wrapper, start_ts, end_ts)
|
||||
bisect.run(progress=progress)
|
||||
env.unset_log_file()
|
||||
|
||||
if bisect.first_bad is None:
|
||||
print('\nNo regression found in the given date range.')
|
||||
return
|
||||
|
||||
title = env.commit_title(bisect.first_bad).replace('`', '\'')
|
||||
print(f'\nRegression introduced by commit `{bisect.first_bad}`: `{title}`')
|
||||
|
||||
|
||||
def cmd_graph(argv: list):
|
||||
# Create graph from a given JSON results file.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('json_file', nargs='+')
|
||||
parser.add_argument('-o', '--output', type=str, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# For directories, use all json files in the directory.
|
||||
json_files = []
|
||||
for path in args.json_file:
|
||||
path = pathlib.Path(path)
|
||||
if path.is_dir():
|
||||
for filepath in glob.iglob(str(path / '*.json')):
|
||||
json_files.append(pathlib.Path(filepath))
|
||||
else:
|
||||
json_files.append(path)
|
||||
|
||||
graph = api.TestGraph(json_files)
|
||||
graph.write(pathlib.Path(args.output))
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig()
|
||||
usage = ('benchmark <command> [<args>]\n'
|
||||
'\n'
|
||||
'Commands:\n'
|
||||
' init [--build] Init benchmarks directory and default config\n'
|
||||
' Optionally with automated revision building setup\n'
|
||||
' \n'
|
||||
' list List available tests, devices and configurations\n'
|
||||
' \n'
|
||||
' run [<config>] [<test>] Execute all tests in configuration\n'
|
||||
' update [<config>] [<test>] Execute only queued and outdated tests\n'
|
||||
' reset [<config>] [<test>] Clear tests results in configuration\n'
|
||||
' status [<config>] [<test>] List configurations and their tests\n'
|
||||
' \n'
|
||||
' graph a.json b.json... -o out.html Create graph from results in JSON files\n'
|
||||
' \n'
|
||||
' bisect Find commit that introduced a regression'
|
||||
' between dates\n')
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Blender performance testing',
|
||||
usage=usage)
|
||||
|
||||
parser.add_argument('command', nargs='?', default='help')
|
||||
args = parser.parse_args(sys.argv[1:2])
|
||||
|
||||
argv = sys.argv[2:]
|
||||
blender_git_dir = find_blender_git_dir()
|
||||
if blender_git_dir is None:
|
||||
sys.stderr.write('Error: no blender git repository found from current working directory\n')
|
||||
sys.exit(1)
|
||||
|
||||
if args.command == 'graph':
|
||||
cmd_graph(argv)
|
||||
sys.exit(0)
|
||||
|
||||
base_dir = get_tests_base_dir(blender_git_dir)
|
||||
env = api.TestEnvironment(blender_git_dir, base_dir)
|
||||
if args.command == 'init':
|
||||
cmd_init(env, argv)
|
||||
sys.exit(0)
|
||||
|
||||
if not env.base_dir.exists():
|
||||
sys.stderr.write(
|
||||
'Error: benchmark directory not initialized. '
|
||||
'Run the \"init\" command to create the directory and a default configuration.\n')
|
||||
sys.exit(1)
|
||||
|
||||
if args.command == 'list':
|
||||
cmd_list(env, argv)
|
||||
elif args.command == 'run':
|
||||
cmd_run(env, argv, update_only=False)
|
||||
elif args.command == 'update':
|
||||
cmd_run(env, argv, update_only=True)
|
||||
elif args.command == 'reset':
|
||||
cmd_reset(env, argv)
|
||||
elif args.command == 'bisect':
|
||||
cmd_bisect(env, argv)
|
||||
elif args.command == 'status':
|
||||
cmd_status(env, argv)
|
||||
elif args.command == 'help':
|
||||
parser.print_usage()
|
||||
else:
|
||||
sys.stderr.write(f'Unknown command: {args.command}\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
3
blender-5.2.0/tests/performance/tests/__init__.py
Normal file
3
blender-5.2.0/tests/performance/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2021 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
50
blender-5.2.0/tests/performance/tests/animation.py
Normal file
50
blender-5.2.0/tests/performance/tests/animation.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
def _run(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
elapsed_time = 0.0
|
||||
num_frames = 0
|
||||
|
||||
while elapsed_time < 10.0:
|
||||
scene = bpy.context.scene
|
||||
f = scene.frame_current + 1
|
||||
|
||||
if f >= scene.frame_end:
|
||||
f = scene.frame_start
|
||||
scene.frame_set(f)
|
||||
num_frames += 1
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
time_per_frame = elapsed_time / num_frames
|
||||
|
||||
result = {'time': time_per_frame}
|
||||
return result
|
||||
|
||||
|
||||
class AnimationTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "animation"
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
args = {}
|
||||
result, _ = env.run_in_blender(_run, args, [self.filepath])
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('animation/*')
|
||||
return [AnimationTest(filepath) for filepath in filepaths]
|
||||
42
blender-5.2.0/tests/performance/tests/blend_load.py
Normal file
42
blender-5.2.0/tests/performance/tests/blend_load.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
def _run(filepath):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
# Load once to ensure it's cached by OS
|
||||
bpy.ops.wm.open_mainfile(filepath=filepath)
|
||||
bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True)
|
||||
|
||||
# Measure loading the second time
|
||||
start_time = time.time()
|
||||
bpy.ops.wm.open_mainfile(filepath=filepath)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time}
|
||||
return result
|
||||
|
||||
|
||||
class BlendLoadTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "blend_load"
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
result, _ = env.run_in_blender(_run, str(self.filepath))
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('*/*')
|
||||
return [BlendLoadTest(filepath) for filepath in filepaths]
|
||||
195
blender-5.2.0/tests/performance/tests/bpy_rna.py
Normal file
195
blender-5.2.0/tests/performance/tests/bpy_rna.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
def _run_id_instance_access(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
iterations = args["iterations"]
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
for i in range(iterations):
|
||||
bpy.data.scenes[0]
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time}
|
||||
return result
|
||||
|
||||
|
||||
def _run_static_subdata_instance_access(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
iterations = args["iterations"]
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
sce = bpy.data.scenes[0]
|
||||
|
||||
for i in range(iterations):
|
||||
sce.render
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time}
|
||||
return result
|
||||
|
||||
|
||||
def _run_idproperty_access(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
iterations = args["iterations"]
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
sce = bpy.data.scenes[0]
|
||||
|
||||
sce["test"] = 3.14
|
||||
for i in range(iterations):
|
||||
sce["test"] += 0.001
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time}
|
||||
return result
|
||||
|
||||
|
||||
def _run_runtime_group_register_access(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
iterations = args["iterations"]
|
||||
do_register = args.get("do_register", False)
|
||||
do_access = args.get("do_access", False)
|
||||
do_get_set = args.get("do_get_set", False)
|
||||
do_transform = args.get("do_transform", False)
|
||||
property_type = args.get("property_type", 'IntProperty')
|
||||
property_definition_cb = getattr(bpy.props, property_type)
|
||||
|
||||
assert (not (do_get_set and do_transform))
|
||||
|
||||
# Define basic 'transform' callbacks to test setting value,
|
||||
# default to just setting untransformed value for 'unknown'/undefined property types.
|
||||
property_transform_set_cb = {
|
||||
'BoolProperty': lambda v: not v,
|
||||
'IntProperty': lambda v: v + 1,
|
||||
'FloatVectorProperty': lambda v: [v[2] + 1.0, v[0], v[1]],
|
||||
'StringProperty': lambda v: ("B" if (v and v[0] == "A") else "A") + v[1:],
|
||||
}.get(property_type, lambda v: v)
|
||||
|
||||
property_transform_get_cb = {
|
||||
'BoolProperty': lambda v: not v,
|
||||
'IntProperty': lambda v: v - 1,
|
||||
'FloatVectorProperty': lambda v: [v[0], v[1], v[2]],
|
||||
'StringProperty': lambda v: ("B" if (v and v[0] == "A") else "A") + v[1:],
|
||||
}.get(property_type, lambda v: v)
|
||||
|
||||
if do_get_set:
|
||||
class DummyGroup(bpy.types.PropertyGroup):
|
||||
dummy_prop: property_definition_cb(
|
||||
get=lambda self:
|
||||
self.bl_system_properties_get().get(
|
||||
"dummy_prop",
|
||||
(self.bl_rna.properties["dummy_prop"].default_array if
|
||||
self.bl_rna.properties["dummy_prop"].is_array else
|
||||
self.bl_rna.properties["dummy_prop"].default)),
|
||||
set=lambda self, val:
|
||||
self.bl_system_properties_get().__setitem__(
|
||||
"dummy_prop",
|
||||
val),
|
||||
)
|
||||
elif do_transform:
|
||||
class DummyGroup(bpy.types.PropertyGroup):
|
||||
dummy_prop: property_definition_cb(
|
||||
get_transform=lambda self, curr_v, is_set: property_transform_get_cb(curr_v),
|
||||
set_transform=lambda self, curr_v, new_v, is_set: property_transform_set_cb(curr_v),
|
||||
)
|
||||
else:
|
||||
class DummyGroup(bpy.types.PropertyGroup):
|
||||
dummy_prop: property_definition_cb()
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
sce = bpy.data.scenes[0]
|
||||
|
||||
# Test Registration & Unregistration.
|
||||
if do_register:
|
||||
for i in range(iterations):
|
||||
bpy.utils.register_class(DummyGroup)
|
||||
bpy.types.Scene.dummy_group = bpy.props.PointerProperty(type=DummyGroup)
|
||||
del bpy.types.Scene.dummy_group
|
||||
bpy.utils.unregister_class(DummyGroup)
|
||||
|
||||
if do_access:
|
||||
bpy.utils.register_class(DummyGroup)
|
||||
bpy.types.Scene.dummy_group = bpy.props.PointerProperty(type=DummyGroup)
|
||||
|
||||
if do_transform:
|
||||
for i in range(iterations):
|
||||
v = sce.dummy_group.dummy_prop
|
||||
sce.dummy_group.dummy_prop = v
|
||||
else:
|
||||
for i in range(iterations):
|
||||
v = sce.dummy_group.dummy_prop
|
||||
sce.dummy_group.dummy_prop = property_transform_set_cb(v)
|
||||
|
||||
del bpy.types.Scene.dummy_group
|
||||
bpy.utils.unregister_class(DummyGroup)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time}
|
||||
return result
|
||||
|
||||
|
||||
class BPYRNATest(api.Test):
|
||||
def __init__(self, name, callback, iterations, args={}):
|
||||
self.name_ = name
|
||||
self.callback = callback
|
||||
self.iterations = iterations
|
||||
self.args = args
|
||||
|
||||
def name(self):
|
||||
return f"{self.name_} ({int(self.iterations / 1000)}k)"
|
||||
|
||||
def category(self):
|
||||
return "bpy_rna"
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
args = self.args
|
||||
args["iterations"] = self.iterations
|
||||
result, _ = env.run_in_blender(self.callback, args, ["--factory-startup"])
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
return [
|
||||
BPYRNATest("ID Instance Access", _run_id_instance_access, 10000 * 1000),
|
||||
BPYRNATest("Static RNA Struct Instance Access", _run_static_subdata_instance_access, 10000 * 1000),
|
||||
BPYRNATest("IDProperty Access", _run_idproperty_access, 10000 * 1000),
|
||||
BPYRNATest("Py-Defined Struct Register", _run_runtime_group_register_access, 100 * 1000,
|
||||
{"do_register": True}),
|
||||
BPYRNATest("Py-Defined IntProperty Access", _run_runtime_group_register_access, 10000 * 1000,
|
||||
{"do_access": True, "property_type": 'IntProperty'}),
|
||||
BPYRNATest("Py-Defined IntProperty Custom Get/Set Access", _run_runtime_group_register_access, 100 * 1000,
|
||||
{"do_access": True, "do_get_set": True, "property_type": 'IntProperty'}),
|
||||
BPYRNATest("Py-Defined BoolProperty Custom Get/Set Access", _run_runtime_group_register_access, 100 * 1000,
|
||||
{"do_access": True, "do_get_set": True, "property_type": 'BoolProperty'}),
|
||||
BPYRNATest("Py-Defined FloatVectorProperty Custom Get/Set Access", _run_runtime_group_register_access, 100 * 1000,
|
||||
{"do_access": True, "do_get_set": True, "property_type": 'FloatVectorProperty'}),
|
||||
BPYRNATest("Py-Defined StringProperty Custom Get/Set Access", _run_runtime_group_register_access, 10 * 1000,
|
||||
{"do_access": True, "do_get_set": True, "property_type": 'StringProperty'}),
|
||||
BPYRNATest("Py-Defined BoolProperty Custom Transform Access", _run_runtime_group_register_access, 1000 * 1000,
|
||||
{"do_access": True, "do_transform": True, "property_type": 'BoolProperty'}),
|
||||
BPYRNATest("Py-Defined FloatVectorProperty Custom Transform Access", _run_runtime_group_register_access, 1000 * 1000,
|
||||
{"do_access": True, "do_transform": True, "property_type": 'FloatVectorProperty'}),
|
||||
BPYRNATest("Py-Defined StringProperty Custom Transform Access", _run_runtime_group_register_access, 1000 * 1000,
|
||||
{"do_access": True, "do_transform": True, "property_type": 'StringProperty'}),
|
||||
]
|
||||
63
blender-5.2.0/tests/performance/tests/compositor.py
Normal file
63
blender-5.2.0/tests/performance/tests/compositor.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# SPDX-FileCopyrightText: 2025 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
def _run(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
device_type, _ = (args['device_type'].split("-") + [""])[:2]
|
||||
scene = bpy.context.scene
|
||||
scene.render.compositor_device = ('CPU' if device_type == 'CPU' else 'GPU')
|
||||
|
||||
test_time_start = time.time()
|
||||
measured_times = []
|
||||
|
||||
min_measurements = 5
|
||||
max_measurements = 100
|
||||
timeout = 10
|
||||
|
||||
while True:
|
||||
start_time = time.time()
|
||||
bpy.ops.render.render()
|
||||
elapsed_time = time.time() - start_time
|
||||
measured_times.append(elapsed_time)
|
||||
|
||||
if len(measured_times) >= min_measurements and test_time_start + timeout < time.time():
|
||||
break
|
||||
if len(measured_times) >= max_measurements:
|
||||
break
|
||||
|
||||
average_time = sum(measured_times) / len(measured_times)
|
||||
result = {'time': average_time}
|
||||
return result
|
||||
|
||||
|
||||
class CompositorTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "compositor"
|
||||
|
||||
def use_device(self):
|
||||
return True
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
tokens = device_id.split('_')
|
||||
device_type = tokens[0]
|
||||
args = {'device_type': device_type}
|
||||
|
||||
result, _ = env.run_in_blender(_run, args, [self.filepath])
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('compositor/*')
|
||||
return [CompositorTest(filepath) for filepath in filepaths]
|
||||
135
blender-5.2.0/tests/performance/tests/cycles.py
Normal file
135
blender-5.2.0/tests/performance/tests/cycles.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
def _run(args):
|
||||
import bpy
|
||||
|
||||
device_info = args['device_type'].split("-")
|
||||
device_type = device_info[0]
|
||||
|
||||
device_suffixes = device_info[1:]
|
||||
use_hwrt = "RT" in device_suffixes
|
||||
use_osl = "OSL" in device_suffixes
|
||||
|
||||
for suffix in device_suffixes:
|
||||
if suffix not in {"RT", "OSL"}:
|
||||
raise SystemExit(f"Unknown device type suffix {suffix}")
|
||||
|
||||
device_index = args['device_index']
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.render.filepath = args['render_filepath']
|
||||
scene.render.image_settings.media_type = 'IMAGE'
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.cycles.device = 'CPU' if device_type == 'CPU' else 'GPU'
|
||||
|
||||
if scene.cycles.use_adaptive_sampling:
|
||||
# Render samples specified in file, no other way to measure
|
||||
# adaptive sampling performance reliably.
|
||||
scene.cycles.time_limit = 0.0
|
||||
else:
|
||||
# Render for fixed amount of time so it's adaptive to the
|
||||
# machine and devices.
|
||||
scene.cycles.samples = 16384
|
||||
scene.cycles.time_limit = 10.0
|
||||
|
||||
if use_osl:
|
||||
scene.cycles.shading_system = True
|
||||
|
||||
if scene.cycles.device == 'GPU':
|
||||
# Enable specified GPU in preferences.
|
||||
prefs = bpy.context.preferences
|
||||
cprefs = prefs.addons['cycles'].preferences
|
||||
cprefs.compute_device_type = device_type
|
||||
devices = cprefs.get_devices_for_type(device_type)
|
||||
for device in devices:
|
||||
device.use = False
|
||||
|
||||
index = 0
|
||||
for device in devices:
|
||||
if device.type == device_type:
|
||||
if index == device_index:
|
||||
device.use = True
|
||||
break
|
||||
else:
|
||||
index += 1
|
||||
|
||||
cprefs.use_hiprt = use_hwrt
|
||||
cprefs.use_oneapirt = use_hwrt
|
||||
cprefs.metalrt = 'ON' if use_hwrt else 'OFF'
|
||||
|
||||
# Render
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class CyclesTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "cycles"
|
||||
|
||||
def use_device(self):
|
||||
return True
|
||||
|
||||
def supported_device_types(self):
|
||||
return [
|
||||
"CPU", "CPU-OSL", "CUDA", "OPTIX", "OPTIX-OSL", "ONEAPI", "ONEAPI-RT", "HIP", "HIP-RT", "METAL", "METAL-RT"
|
||||
]
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
tokens = device_id.split('_')
|
||||
device_type = tokens[0]
|
||||
device_index = int(tokens[1]) if len(tokens) > 1 else 0
|
||||
args = {'device_type': device_type,
|
||||
'device_index': device_index,
|
||||
'render_filepath': str(env.log_file.parent / (env.log_file.stem + '.png'))}
|
||||
|
||||
_, lines = env.run_in_blender(_run, args, ['--debug-cycles', '--verbose', '2', self.filepath])
|
||||
|
||||
# Parse render time from output
|
||||
prefix_time = "Render time (without synchronization): "
|
||||
prefix_memory = "Peak: "
|
||||
prefix_time_per_sample = "Average time per sample: "
|
||||
time = None
|
||||
time_per_sample = None
|
||||
memory = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
offset = line.find(prefix_time)
|
||||
if offset != -1:
|
||||
time = line[offset + len(prefix_time):]
|
||||
time = float(time)
|
||||
offset = line.find(prefix_time_per_sample)
|
||||
if offset != -1:
|
||||
time_per_sample = line[offset + len(prefix_time_per_sample):]
|
||||
time_per_sample = time_per_sample.split()[0]
|
||||
time_per_sample = float(time_per_sample)
|
||||
offset = line.find(prefix_memory)
|
||||
if offset != -1:
|
||||
memory = line[offset + len(prefix_memory):]
|
||||
memory = memory.split()[0].replace(',', '')
|
||||
memory = float(memory)
|
||||
|
||||
if time_per_sample:
|
||||
time = time_per_sample
|
||||
|
||||
if not (time and memory):
|
||||
raise Exception("Error parsing render time output")
|
||||
|
||||
return {'time': time, 'peak_memory': memory}
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('cycles/*')
|
||||
return [CyclesTest(filepath) for filepath in filepaths]
|
||||
161
blender-5.2.0/tests/performance/tests/eevee.py
Normal file
161
blender-5.2.0/tests/performance/tests/eevee.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# SPDX-FileCopyrightText: 2022 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import enum
|
||||
import time
|
||||
|
||||
|
||||
class RecordStage(enum.Enum):
|
||||
INIT = 0,
|
||||
WAIT_SHADERS = 1,
|
||||
WARMUP = 2,
|
||||
RECORD = 3,
|
||||
FINISHED = 4
|
||||
|
||||
|
||||
WARMUP_SECONDS = 3
|
||||
WARMUP_FRAMES = 10
|
||||
SHADER_FALLBACK_SECONDS = 60
|
||||
RECORD_PLAYBACK_ITER = 3
|
||||
MIN_NUM_FRAMES_TOTAL = 250
|
||||
LOG_KEY = "ANIMATION_PERFORMANCE: "
|
||||
|
||||
|
||||
def _run(args):
|
||||
import bpy
|
||||
|
||||
global record_stage
|
||||
record_stage = RecordStage.INIT
|
||||
|
||||
bpy.app.handlers.frame_change_post.append(frame_change_handler)
|
||||
bpy.ops.screen.animation_play()
|
||||
|
||||
|
||||
def frame_change_handler(scene):
|
||||
import bpy
|
||||
|
||||
global record_stage
|
||||
global frame_set_mode
|
||||
global start_time
|
||||
global start_record_time
|
||||
global start_warmup_time
|
||||
global warmup_frame
|
||||
global stop_record_time
|
||||
global playback_iteration
|
||||
global num_frames
|
||||
|
||||
if record_stage == RecordStage.INIT:
|
||||
screen = bpy.context.window_manager.windows[0].screen
|
||||
bpy.context.scene.sync_mode = 'NONE'
|
||||
frame_set_mode = False
|
||||
# Overwrite animation FPS limit set by .blend files.
|
||||
bpy.context.scene.render.fps = 1000
|
||||
|
||||
for area in screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
space = area.spaces[0]
|
||||
space.shading.type = 'RENDERED'
|
||||
space.overlay.show_overlays = False
|
||||
|
||||
start_time = time.perf_counter()
|
||||
record_stage = RecordStage.WAIT_SHADERS
|
||||
|
||||
elif record_stage == RecordStage.WAIT_SHADERS:
|
||||
shaders_compiled = False
|
||||
if hasattr(bpy.app, 'is_job_running'):
|
||||
shaders_compiled = not bpy.app.is_job_running("SHADER_COMPILATION")
|
||||
else:
|
||||
# Fallback when is_job_running doesn't exists by waiting for a time.
|
||||
shaders_compiled = time.perf_counter() - start_time > SHADER_FALLBACK_SECONDS
|
||||
|
||||
if shaders_compiled:
|
||||
start_warmup_time = time.perf_counter()
|
||||
warmup_frame = 0
|
||||
record_stage = RecordStage.WARMUP
|
||||
|
||||
elif record_stage == RecordStage.WARMUP:
|
||||
if frame_set_mode:
|
||||
# scene.frame_set results in a recursive call to frame_change_handler.
|
||||
# Avoid running into a RecursionError.
|
||||
return
|
||||
warmup_frame += 1
|
||||
# Check for two-stage shader compilation that can happen later than the first frame.
|
||||
if hasattr(bpy.app, 'is_job_running') and bpy.app.is_job_running("SHADER_COMPILATION"):
|
||||
record_stage = RecordStage.WAIT_SHADERS
|
||||
elif time.perf_counter() - start_warmup_time > WARMUP_SECONDS and warmup_frame > WARMUP_FRAMES:
|
||||
start_record_time = time.perf_counter()
|
||||
playback_iteration = 0
|
||||
num_frames = 0
|
||||
scene = bpy.context.scene
|
||||
frame_set_mode = True
|
||||
scene.frame_set(scene.frame_start)
|
||||
frame_set_mode = False
|
||||
record_stage = RecordStage.RECORD
|
||||
|
||||
elif record_stage == RecordStage.RECORD:
|
||||
current_time = time.perf_counter()
|
||||
scene = bpy.context.scene
|
||||
num_frames += 1
|
||||
if scene.frame_current == scene.frame_end:
|
||||
playback_iteration += 1
|
||||
|
||||
if playback_iteration >= RECORD_PLAYBACK_ITER and num_frames >= MIN_NUM_FRAMES_TOTAL:
|
||||
stop_record_time = current_time
|
||||
record_stage = RecordStage.FINISHED
|
||||
|
||||
elif record_stage == RecordStage.FINISHED:
|
||||
bpy.ops.screen.animation_cancel()
|
||||
elapsed_seconds = stop_record_time - start_record_time
|
||||
avg_frame_time = elapsed_seconds / num_frames
|
||||
fps = 1.0 / avg_frame_time
|
||||
print(f"{LOG_KEY}{{'fps': {fps} }}")
|
||||
bpy.app.handlers.frame_change_post.remove(frame_change_handler)
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
_run(None)
|
||||
|
||||
else:
|
||||
import api
|
||||
|
||||
class EeveeTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "eevee"
|
||||
|
||||
def use_device(self) -> bool:
|
||||
return True
|
||||
|
||||
def supported_device_types(self):
|
||||
return [
|
||||
"METAL", "VULKAN", "OPENGL",
|
||||
]
|
||||
|
||||
def use_background(self):
|
||||
return False
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
args = {}
|
||||
|
||||
blender_args = api.test.Test.blender_gpu_arguments(device_id, gpu_backend)
|
||||
blender_args.append(self.filepath)
|
||||
|
||||
_, log = env.run_in_blender(_run, args, blender_args, foreground=True)
|
||||
for line in log:
|
||||
if line.startswith(LOG_KEY):
|
||||
result_str = line[len(LOG_KEY):]
|
||||
result = eval(result_str)
|
||||
return result
|
||||
|
||||
raise Exception("No playback performance result found in log.")
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('eevee/*')
|
||||
return [EeveeTest(filepath) for filepath in filepaths]
|
||||
65
blender-5.2.0/tests/performance/tests/geometry_nodes.py
Normal file
65
blender-5.2.0/tests/performance/tests/geometry_nodes.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
def _run(args):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
# Evaluate objects once first, to avoid any possible lazy evaluation later.
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
test_time_start = time.time()
|
||||
measured_times = []
|
||||
|
||||
min_measurements = 5
|
||||
max_measurements = 100
|
||||
timeout = 5
|
||||
|
||||
while True:
|
||||
# Tag all objects with geometry nodes modifiers to be recalculated.
|
||||
for ob in bpy.context.view_layer.objects:
|
||||
for modifier in ob.modifiers:
|
||||
if modifier.type == 'NODES':
|
||||
ob.update_tag()
|
||||
break
|
||||
|
||||
start_time = time.time()
|
||||
bpy.context.view_layer.update()
|
||||
elapsed_time = time.time() - start_time
|
||||
measured_times.append(elapsed_time)
|
||||
|
||||
if len(measured_times) >= min_measurements and test_time_start + timeout < time.time():
|
||||
break
|
||||
if len(measured_times) >= max_measurements:
|
||||
break
|
||||
|
||||
average_time = sum(measured_times) / len(measured_times)
|
||||
result = {'time': average_time}
|
||||
return result
|
||||
|
||||
|
||||
class GeometryNodesTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "geometry_nodes"
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
args = {}
|
||||
|
||||
result, _ = env.run_in_blender(_run, args, [self.filepath])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('geometry_nodes/*')
|
||||
return [GeometryNodesTest(filepath) for filepath in filepaths]
|
||||
132
blender-5.2.0/tests/performance/tests/grease_pencil.py
Normal file
132
blender-5.2.0/tests/performance/tests/grease_pencil.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
import enum
|
||||
import time
|
||||
|
||||
|
||||
class RecordStage(enum.Enum):
|
||||
INIT = 0,
|
||||
WARMUP = 1,
|
||||
RECORD = 2,
|
||||
FINISHED = 3
|
||||
|
||||
|
||||
WARMUP_SECONDS = 4
|
||||
WARMUP_FRAMES = 10
|
||||
RECORD_PLAYBACK_ITER = 3
|
||||
MIN_NUM_FRAMES_TOTAL = 250
|
||||
LOG_KEY = "VIEWPORT_PERFORMANCE: "
|
||||
|
||||
|
||||
def _run(args):
|
||||
import bpy
|
||||
|
||||
global record_stage
|
||||
record_stage = RecordStage.INIT
|
||||
|
||||
bpy.app.handlers.frame_change_post.append(frame_change_handler)
|
||||
bpy.ops.screen.animation_play()
|
||||
|
||||
|
||||
def frame_change_handler(scene):
|
||||
import bpy
|
||||
|
||||
global record_stage
|
||||
global frame_set_mode
|
||||
global start_record_time
|
||||
global start_warmup_time
|
||||
global warmup_frame
|
||||
global stop_record_time
|
||||
global playback_iteration
|
||||
global num_frames
|
||||
|
||||
if record_stage == RecordStage.INIT:
|
||||
bpy.context.scene.sync_mode = 'NONE'
|
||||
frame_set_mode = False
|
||||
# Overwrite animation FPS limit set by .blend files.
|
||||
bpy.context.scene.render.fps = 1000
|
||||
|
||||
start_warmup_time = time.perf_counter()
|
||||
warmup_frame = 0
|
||||
record_stage = RecordStage.WARMUP
|
||||
|
||||
elif record_stage == RecordStage.WARMUP:
|
||||
if frame_set_mode:
|
||||
# scene.frame_set results in a recursive call to frame_change_handler.
|
||||
# Avoid running into a RecursionError.
|
||||
return
|
||||
warmup_frame += 1
|
||||
if time.perf_counter() - start_warmup_time > WARMUP_SECONDS and warmup_frame > WARMUP_FRAMES:
|
||||
start_record_time = time.perf_counter()
|
||||
playback_iteration = 0
|
||||
num_frames = 0
|
||||
scene = bpy.context.scene
|
||||
frame_set_mode = True
|
||||
scene.frame_set(scene.frame_start)
|
||||
frame_set_mode = False
|
||||
record_stage = RecordStage.RECORD
|
||||
|
||||
elif record_stage == RecordStage.RECORD:
|
||||
current_time = time.perf_counter()
|
||||
scene = bpy.context.scene
|
||||
num_frames += 1
|
||||
if scene.frame_current == scene.frame_end:
|
||||
playback_iteration += 1
|
||||
|
||||
if playback_iteration >= RECORD_PLAYBACK_ITER and num_frames >= MIN_NUM_FRAMES_TOTAL:
|
||||
stop_record_time = current_time
|
||||
record_stage = RecordStage.FINISHED
|
||||
|
||||
elif record_stage == RecordStage.FINISHED:
|
||||
bpy.ops.screen.animation_cancel()
|
||||
elapsed_seconds = stop_record_time - start_record_time
|
||||
avg_frame_time = elapsed_seconds / num_frames
|
||||
fps = 1.0 / avg_frame_time
|
||||
print(f"{LOG_KEY}{{'fps': {fps} }}")
|
||||
bpy.app.handlers.frame_change_post.remove(frame_change_handler)
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
|
||||
class GreasePencilTest(api.Test):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return self.filepath.stem
|
||||
|
||||
def category(self):
|
||||
return "grease_pencil"
|
||||
|
||||
def use_device(self) -> bool:
|
||||
return True
|
||||
|
||||
def supported_device_types(self):
|
||||
return [
|
||||
"METAL", "VULKAN", "OPENGL"
|
||||
]
|
||||
|
||||
def use_background(self):
|
||||
return False
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
args = {}
|
||||
|
||||
blender_args = api.test.Test.blender_gpu_arguments(device_id, gpu_backend)
|
||||
blender_args.append(self.filepath)
|
||||
|
||||
_, log = env.run_in_blender(_run, args, blender_args, foreground=True)
|
||||
for line in log:
|
||||
if line.startswith(LOG_KEY):
|
||||
result_str = line[len(LOG_KEY):]
|
||||
result = eval(result_str)
|
||||
return result
|
||||
|
||||
raise Exception("No playback performance result found in log.")
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('grease_pencil/*')
|
||||
return [GreasePencilTest(filepath) for filepath in filepaths]
|
||||
382
blender-5.2.0/tests/performance/tests/sculpt.py
Normal file
382
blender-5.2.0/tests/performance/tests/sculpt.py
Normal file
@@ -0,0 +1,382 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
import enum
|
||||
import pathlib
|
||||
|
||||
|
||||
class SculptMode(enum.IntEnum):
|
||||
MESH = 1
|
||||
MULTIRES = 2
|
||||
DYNTOPO = 3
|
||||
|
||||
|
||||
class BrushType(enum.Enum):
|
||||
DRAW = "Draw"
|
||||
CLAY_STRIPS = "Clay Strips"
|
||||
SMOOTH = "Smooth"
|
||||
|
||||
|
||||
def set_view3d_context_override(context_override):
|
||||
"""
|
||||
Set context override to become the first viewport in the active workspace
|
||||
|
||||
The ``context_override`` is expected to be a copy of an actual current context
|
||||
obtained by `context.copy()`
|
||||
"""
|
||||
|
||||
for area in context_override["screen"].areas:
|
||||
if area.type != 'VIEW_3D':
|
||||
continue
|
||||
for space in area.spaces:
|
||||
if space.type != 'VIEW_3D':
|
||||
continue
|
||||
for region in area.regions:
|
||||
if region.type != 'WINDOW':
|
||||
continue
|
||||
context_override["area"] = area
|
||||
context_override["region"] = region
|
||||
|
||||
|
||||
def prepare_sculpt_scene(context: any, mode: SculptMode, subdivision_level=3):
|
||||
"""
|
||||
Prepare a clean state of the scene suitable for benchmarking
|
||||
|
||||
It creates a high-res object and moves it to a sculpt mode.
|
||||
|
||||
For dyntopo & normal mesh sculpting, we create a grid with 2.2M vertices.
|
||||
For multires sculpting, we create a grid with 22k vertices - with a multires
|
||||
modifier set to level 3, this results in an equivalent number of 2.2M vertices
|
||||
inside sculpt mode.
|
||||
"""
|
||||
import bpy
|
||||
|
||||
# Ensure the current mode is object, as it might not be the always the case
|
||||
# if the benchmark script is run from a non-clean state of the .blend file.
|
||||
if context.object:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Delete all current objects from the scene.
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
bpy.ops.outliner.orphans_purge()
|
||||
|
||||
group = bpy.data.node_groups.new("Test", 'GeometryNodeTree')
|
||||
group.interface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry')
|
||||
group_output_node = group.nodes.new('NodeGroupOutput')
|
||||
|
||||
if mode == SculptMode.MESH:
|
||||
size = 1500
|
||||
elif mode == SculptMode.MULTIRES:
|
||||
size = 150
|
||||
elif mode == SculptMode.DYNTOPO:
|
||||
size = 500
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
grid_node = group.nodes.new('GeometryNodeMeshGrid')
|
||||
grid_node.inputs["Size X"].default_value = 2.0
|
||||
grid_node.inputs["Size Y"].default_value = 2.0
|
||||
grid_node.inputs["Vertices X"].default_value = size
|
||||
grid_node.inputs["Vertices Y"].default_value = size
|
||||
|
||||
group.links.new(grid_node.outputs["Mesh"], group_output_node.inputs[0])
|
||||
|
||||
bpy.ops.mesh.primitive_plane_add(size=2, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
|
||||
|
||||
ob = context.object
|
||||
md = ob.modifiers.new("Test", 'NODES')
|
||||
md.node_group = group
|
||||
|
||||
bpy.ops.object.modifier_apply(modifier="Test")
|
||||
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
# Move the plane to the sculpt mode.
|
||||
bpy.ops.object.mode_set(mode='SCULPT')
|
||||
|
||||
if mode == SculptMode.MULTIRES:
|
||||
bpy.ops.object.subdivision_set(level=subdivision_level)
|
||||
elif mode == SculptMode.DYNTOPO:
|
||||
bpy.ops.sculpt.dynamic_topology_toggle()
|
||||
|
||||
|
||||
def prepare_brush(context: any, brush_type: BrushType):
|
||||
"""Activates and sets common brush settings"""
|
||||
import bpy
|
||||
bpy.ops.brush.asset_activate(
|
||||
asset_library_type='ESSENTIALS',
|
||||
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/' +
|
||||
brush_type.value)
|
||||
|
||||
# Reduce the brush strength to avoid deforming the mesh too much and influencing multiple strokes
|
||||
context.tool_settings.sculpt.brush.strength = 0.1
|
||||
|
||||
|
||||
def generate_stroke(context):
|
||||
"""
|
||||
Generate stroke for the bpy.ops.sculpt.brush_stroke operator
|
||||
|
||||
The generated stroke coves the full plane diagonal.
|
||||
"""
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
template = {
|
||||
"name": "stroke",
|
||||
"mouse": (0.0, 0.0),
|
||||
"mouse_event": (0, 0),
|
||||
"is_start": True,
|
||||
"location": (0, 0, 0),
|
||||
"pressure": 1.0,
|
||||
"time": 1.0,
|
||||
"size": 1.0,
|
||||
"x_tilt": 0,
|
||||
"y_tilt": 0
|
||||
}
|
||||
|
||||
version = bpy.app.version
|
||||
if version[0] <= 4 and version[1] <= 3:
|
||||
template["pen_flip"] = False
|
||||
|
||||
num_steps = 100
|
||||
start = Vector((context['area'].width, context['area'].height))
|
||||
end = Vector((0, 0))
|
||||
delta = (end - start) / (num_steps - 1)
|
||||
|
||||
stroke = []
|
||||
for i in range(num_steps):
|
||||
step = template.copy()
|
||||
step["mouse_event"] = start + delta * i
|
||||
stroke.append(step)
|
||||
|
||||
return stroke
|
||||
|
||||
|
||||
def _run_brush_test(args: dict):
|
||||
import bpy
|
||||
import time
|
||||
context = bpy.context
|
||||
|
||||
timeout = 10
|
||||
total_time_start = time.time()
|
||||
|
||||
# Create an undo stack explicitly. This isn't created by default in background mode.
|
||||
bpy.ops.ed.undo_push()
|
||||
|
||||
prepare_brush(context, args['brush_type'])
|
||||
|
||||
min_measurements = 5
|
||||
max_measurements = 100
|
||||
measurements = []
|
||||
while True:
|
||||
prepare_sculpt_scene(context, args['mode'])
|
||||
context_override = context.copy()
|
||||
set_view3d_context_override(context_override)
|
||||
with context.temp_override(**context_override):
|
||||
if args.get('spatial_reorder', False):
|
||||
bpy.ops.mesh.reorder_vertices_spatial()
|
||||
bpy.ops.ed.undo_push()
|
||||
start = time.time()
|
||||
bpy.ops.sculpt.brush_stroke(stroke=generate_stroke(context_override), override_location=True)
|
||||
bpy.ops.ed.undo_push()
|
||||
measurements.append(time.time() - start)
|
||||
memory_info = bpy.app.memory_usage_undo()
|
||||
if len(measurements) >= min_measurements and (time.time() - total_time_start) > timeout:
|
||||
break
|
||||
if len(measurements) >= max_measurements:
|
||||
break
|
||||
|
||||
return {'time': sum(measurements) / len(measurements), 'memory': memory_info}
|
||||
|
||||
|
||||
def _run_bvh_test(args: dict):
|
||||
import bpy
|
||||
import time
|
||||
context = bpy.context
|
||||
|
||||
timeout = 10
|
||||
total_time_start = time.time()
|
||||
|
||||
# Create an undo stack explicitly. This isn't created by default in background mode.
|
||||
bpy.ops.ed.undo_push()
|
||||
|
||||
min_measurements = 5
|
||||
max_measurements = 100
|
||||
|
||||
measurements = []
|
||||
while True:
|
||||
prepare_sculpt_scene(context, args['mode'])
|
||||
context_override = context.copy()
|
||||
set_view3d_context_override(context_override)
|
||||
with context.temp_override(**context_override):
|
||||
if args.get('spatial_reorder', False):
|
||||
bpy.ops.mesh.reorder_vertices_spatial()
|
||||
start = time.time()
|
||||
bpy.ops.sculpt.optimize()
|
||||
measurements.append(time.time() - start)
|
||||
|
||||
if len(measurements) >= min_measurements and (time.time() - total_time_start) > timeout:
|
||||
break
|
||||
if len(measurements) >= max_measurements:
|
||||
break
|
||||
|
||||
return sum(measurements) / len(measurements)
|
||||
|
||||
|
||||
def _run_subdivide_test(_args: dict):
|
||||
import bpy
|
||||
import time
|
||||
context = bpy.context
|
||||
|
||||
timeout = 10
|
||||
total_time_start = time.time()
|
||||
|
||||
# Create an undo stack explicitly. This isn't created by default in background mode.
|
||||
bpy.ops.ed.undo_push()
|
||||
|
||||
min_measurements = 5
|
||||
max_measurements = 100
|
||||
|
||||
measurements = []
|
||||
while True:
|
||||
prepare_sculpt_scene(context, SculptMode.MULTIRES, subdivision_level=2)
|
||||
context_override = context.copy()
|
||||
set_view3d_context_override(context_override)
|
||||
with context.temp_override(**context_override):
|
||||
start = time.time()
|
||||
bpy.ops.object.multires_subdivide(modifier="Multires")
|
||||
measurements.append(time.time() - start)
|
||||
|
||||
if len(measurements) >= min_measurements and (time.time() - total_time_start) > timeout:
|
||||
break
|
||||
if len(measurements) >= max_measurements:
|
||||
break
|
||||
|
||||
return sum(measurements) / len(measurements)
|
||||
|
||||
|
||||
class SculptBrushTest(api.Test):
|
||||
def __init__(self, filepath: pathlib.Path, mode: SculptMode, brush_type: BrushType):
|
||||
self.filepath = filepath
|
||||
self.mode = mode
|
||||
self.brush_type = brush_type
|
||||
|
||||
def name(self):
|
||||
return "{}_{}".format(self.mode.name.lower(), self.brush_type.name.lower())
|
||||
|
||||
def category(self):
|
||||
return "sculpt"
|
||||
|
||||
def run(self, env, _device_id, _gpu_backend):
|
||||
args = {
|
||||
'mode': self.mode,
|
||||
'brush_type': self.brush_type,
|
||||
'spatial_reorder': False,
|
||||
}
|
||||
|
||||
result, _ = env.run_in_blender(_run_brush_test, args, [self.filepath])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class SculptBrushAfterSpatialReorderingTest(api.Test):
|
||||
def __init__(self, filepath: pathlib.Path, mode: SculptMode, brush_type: BrushType):
|
||||
self.filepath = filepath
|
||||
self.mode = mode
|
||||
self.brush_type = brush_type
|
||||
|
||||
def name(self):
|
||||
return "{}_{}_{}".format(self.mode.name.lower(), self.brush_type.name.lower(), "after_reordering")
|
||||
|
||||
def category(self):
|
||||
return "sculpt"
|
||||
|
||||
def run(self, env, _device_id, _gpu_backend):
|
||||
args = {
|
||||
'mode': self.mode,
|
||||
'brush_type': self.brush_type,
|
||||
'spatial_reorder': True,
|
||||
}
|
||||
|
||||
result, _ = env.run_in_blender(_run_brush_test, args, [self.filepath])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class SculptRebuildBVHTest(api.Test):
|
||||
def __init__(self, filepath: pathlib.Path, mode: SculptMode):
|
||||
self.filepath = filepath
|
||||
self.mode = mode
|
||||
|
||||
def name(self):
|
||||
return "{}_rebuild_bvh".format(self.mode.name.lower())
|
||||
|
||||
def category(self):
|
||||
return "sculpt"
|
||||
|
||||
def run(self, env, _device_id, _gpu_backend):
|
||||
args = {
|
||||
'mode': self.mode,
|
||||
'spatial_reorder': False,
|
||||
}
|
||||
|
||||
result, _ = env.run_in_blender(_run_bvh_test, args, [self.filepath])
|
||||
|
||||
return {'time': result}
|
||||
|
||||
|
||||
class SculptRebuildSpatialBVHTest(api.Test):
|
||||
def __init__(self, filepath: pathlib.Path, mode: SculptMode):
|
||||
self.filepath = filepath
|
||||
self.mode = mode
|
||||
|
||||
def name(self):
|
||||
return "{}_spatial_rebuild_bvh".format(self.mode.name.lower())
|
||||
|
||||
def category(self):
|
||||
return "sculpt"
|
||||
|
||||
def run(self, env, _device_id, _gpu_backend):
|
||||
args = {
|
||||
'mode': self.mode,
|
||||
'spatial_reorder': True,
|
||||
}
|
||||
|
||||
result, _ = env.run_in_blender(_run_bvh_test, args, [self.filepath])
|
||||
|
||||
return {'time': result}
|
||||
|
||||
|
||||
class SculptMultiresSubdivideTest(api.Test):
|
||||
def __init__(self, filepath: pathlib.Path):
|
||||
self.filepath = filepath
|
||||
|
||||
def name(self):
|
||||
return "multires_subdivide_2_to_3"
|
||||
|
||||
def category(self):
|
||||
return "sculpt"
|
||||
|
||||
def run(self, env, _device_id, _gpu_backend):
|
||||
result, _ = env.run_in_blender(_run_subdivide_test, {}, [self.filepath])
|
||||
|
||||
return {'time': result}
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('sculpt/*')
|
||||
# For now, we only expect there to ever be a single file to use as the basis for generating other brush tests
|
||||
assert len(filepaths) == 1
|
||||
|
||||
brush_tests = [SculptBrushTest(filepaths[0], mode, brush_type) for mode in SculptMode for brush_type in BrushType]
|
||||
brush_tests_after_reordering = [
|
||||
SculptBrushAfterSpatialReorderingTest(
|
||||
filepaths[0],
|
||||
SculptMode.MESH,
|
||||
brush_type)for brush_type in BrushType]
|
||||
bvh_tests = [SculptRebuildBVHTest(filepaths[0], mode) for mode in SculptMode]
|
||||
spatial_bvh_tests = [SculptRebuildSpatialBVHTest(filepaths[0], SculptMode.MESH)]
|
||||
subdivision_tests = [SculptMultiresSubdivideTest(filepaths[0])]
|
||||
return brush_tests + brush_tests_after_reordering + bvh_tests + spatial_bvh_tests + subdivision_tests
|
||||
216
blender-5.2.0/tests/performance/tests/texture_paint.py
Normal file
216
blender-5.2.0/tests/performance/tests/texture_paint.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
import enum
|
||||
import pathlib
|
||||
|
||||
|
||||
class MeshType(enum.IntEnum):
|
||||
CUBE = 0
|
||||
MONKEY = 1
|
||||
SUBDIV_3_MONKEY = 2
|
||||
|
||||
|
||||
class DataType(enum.IntEnum):
|
||||
BYTE = 0
|
||||
FLOAT = 1
|
||||
|
||||
|
||||
DIMENSIONS = [1024, 4096]
|
||||
|
||||
|
||||
def set_view3d_context_override(context_override):
|
||||
"""
|
||||
Set context override to become the first viewport in the active workspace
|
||||
|
||||
The ``context_override`` is expected to be a copy of an actual current context
|
||||
obtained by `context.copy()`
|
||||
"""
|
||||
|
||||
for area in context_override["screen"].areas:
|
||||
if area.type != 'VIEW_3D':
|
||||
continue
|
||||
for space in area.spaces:
|
||||
if space.type != 'VIEW_3D':
|
||||
continue
|
||||
for region in area.regions:
|
||||
if region.type != 'WINDOW':
|
||||
continue
|
||||
context_override["area"] = area
|
||||
context_override["region"] = region
|
||||
|
||||
|
||||
def prepare_scene(context: any, object: MeshType, image_dimension: int, data_type: DataType):
|
||||
"""
|
||||
Prepare a clean state of the scene suitable for benchmarking
|
||||
"""
|
||||
import bpy
|
||||
|
||||
bpy.context.preferences.experimental.use_sculpt_texture_paint = True
|
||||
|
||||
# Ensure the current mode is object, as it might not be always the case
|
||||
# if the benchmark script is run from a non-clean state of the .blend file.
|
||||
if context.object:
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Delete all current objects from the scene.
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
bpy.ops.outliner.orphans_purge()
|
||||
|
||||
if object == MeshType.MONKEY:
|
||||
bpy.ops.mesh.primitive_monkey_add(size=2, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
|
||||
elif object == MeshType.CUBE:
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
|
||||
elif object == MeshType.SUBDIV_3_MONKEY:
|
||||
bpy.ops.mesh.primitive_monkey_add(size=2, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
|
||||
bpy.ops.object.subdivision_set(level=3, relative=False, ensure_modifier=True)
|
||||
bpy.ops.object.modifier_apply(modifier="Subdivision")
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
context_override = context.copy()
|
||||
set_view3d_context_override(context_override)
|
||||
with context.temp_override(**context_override):
|
||||
bpy.ops.view3d.view_axis(type='FRONT')
|
||||
bpy.ops.view3d.view_selected()
|
||||
bpy.ops.object.mode_set(mode='SCULPT')
|
||||
|
||||
is_float_image = data_type == DataType.FLOAT
|
||||
|
||||
bpy.ops.paint.add_texture_paint_slot(
|
||||
type='BASE_COLOR',
|
||||
slot_type='IMAGE',
|
||||
name="Untitled",
|
||||
color=(
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0),
|
||||
width=image_dimension,
|
||||
height=image_dimension,
|
||||
alpha=True,
|
||||
generated_type='BLANK',
|
||||
float=is_float_image)
|
||||
|
||||
|
||||
def prepare_brush():
|
||||
import bpy
|
||||
bpy.ops.brush.asset_activate(
|
||||
asset_library_type='ESSENTIALS',
|
||||
relative_asset_identifier="brushes/essentials_brushes-mesh_sculpt.blend/Brush/Paint Hard")
|
||||
|
||||
|
||||
def generate_stroke(context):
|
||||
"""
|
||||
Generate stroke for the bpy.ops.sculpt.brush_stroke operator
|
||||
|
||||
The generated stroke coves the full plane diagonal.
|
||||
"""
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
template = {
|
||||
"name": "stroke",
|
||||
"mouse": (0.0, 0.0),
|
||||
"mouse_event": (0, 0),
|
||||
"is_start": True,
|
||||
"location": (0, 0, 0),
|
||||
"pressure": 1.0,
|
||||
"time": 1.0,
|
||||
"size": 1.0,
|
||||
"x_tilt": 0,
|
||||
"y_tilt": 0
|
||||
}
|
||||
|
||||
version = bpy.app.version
|
||||
if version[0] <= 4 and version[1] <= 3:
|
||||
template["pen_flip"] = False
|
||||
|
||||
num_steps = 100
|
||||
start = Vector((context["area"].width, context["area"].height))
|
||||
end = Vector((0, 0))
|
||||
delta = (end - start) / (num_steps - 1)
|
||||
|
||||
stroke = []
|
||||
for i in range(num_steps):
|
||||
step = template.copy()
|
||||
step["mouse_event"] = start + delta * i
|
||||
stroke.append(step)
|
||||
|
||||
return stroke
|
||||
|
||||
|
||||
def _run_brush_test(args: dict):
|
||||
import bpy
|
||||
import time
|
||||
|
||||
# This test can only run in alpha, for now, due to the texture paint mode being an experimental feature
|
||||
if bpy.app.version_cycle != 'alpha':
|
||||
return {"time": 0.0}
|
||||
|
||||
context = bpy.context
|
||||
|
||||
timeout = 10
|
||||
total_time_start = time.time()
|
||||
|
||||
# Create an undo stack explicitly. This isn't created by default in background mode.
|
||||
bpy.ops.ed.undo_push()
|
||||
|
||||
prepare_brush()
|
||||
|
||||
min_measurements = 5
|
||||
max_measurements = 100
|
||||
measurements = []
|
||||
while True:
|
||||
prepare_scene(context, args["object_type"], args["dimension"], args["data_type"])
|
||||
context_override = context.copy()
|
||||
set_view3d_context_override(context_override)
|
||||
with context.temp_override(**context_override):
|
||||
start = time.time()
|
||||
bpy.ops.sculpt.brush_stroke(stroke=generate_stroke(context_override), override_location=True)
|
||||
bpy.ops.ed.undo_push()
|
||||
measurements.append(time.time() - start)
|
||||
if len(measurements) >= min_measurements and (time.time() - total_time_start) > timeout:
|
||||
break
|
||||
if len(measurements) >= max_measurements:
|
||||
break
|
||||
|
||||
return {"time": sum(measurements) / len(measurements)}
|
||||
|
||||
|
||||
class TexturePaintBrushTest(api.Test):
|
||||
def __init__(self, filepath: pathlib.Path, object_type: MeshType, dimension: int, data_type: DataType):
|
||||
self.filepath = filepath
|
||||
self.object_type = object_type
|
||||
self.dimension = dimension
|
||||
self.data_type = data_type
|
||||
|
||||
def name(self):
|
||||
return "{}_{}_{}".format(self.object_type.name.lower(), self.data_type.name.lower(), self.dimension)
|
||||
|
||||
def category(self):
|
||||
return "texture_paint"
|
||||
|
||||
def run(self, env, _device_id, _gpu_backend):
|
||||
args = {
|
||||
'object_type': self.object_type,
|
||||
'dimension': self.dimension,
|
||||
'data_type': self.data_type
|
||||
}
|
||||
|
||||
result, _ = env.run_in_blender(_run_brush_test, args, [self.filepath])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
filepaths = env.find_blend_files('texture_paint/*')
|
||||
# For now, we only expect there to ever be a single file to use as the basis for generating other brush tests
|
||||
assert len(filepaths) == 1
|
||||
|
||||
brush_tests = [TexturePaintBrushTest(filepaths[0], object_type, dimension, data_type)
|
||||
for object_type in MeshType for dimension in DIMENSIONS for data_type in DataType]
|
||||
return brush_tests
|
||||
138
blender-5.2.0/tests/performance/tests/undo.py
Normal file
138
blender-5.2.0/tests/performance/tests/undo.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import api
|
||||
|
||||
|
||||
# Validate performances when one heavy geometry is in the scene:
|
||||
# - Writing and loading memfile undo steps of changes in the heavy geometry itself.
|
||||
# - Writing and loading memfile undo steps of changes to the object using the heavy geometry.
|
||||
def _run_heavy_geometry(dummy_):
|
||||
import bpy
|
||||
import mathutils
|
||||
import time
|
||||
|
||||
ob = bpy.data.objects["Cube"]
|
||||
assert (bpy.context.object == ob)
|
||||
ob.modifiers.new(name="Subsurf", type='SUBSURF').levels = 9
|
||||
bpy.ops.object.modifier_apply(modifier="Subsurf")
|
||||
|
||||
start_time = time.time()
|
||||
# NOTE: The first undo push is necessary to be able to undo, since it creates the
|
||||
# initial state for memfile undo (it is not initialized by default in background mode).
|
||||
bpy.ops.ed.undo_push()
|
||||
|
||||
# Empty undo step.
|
||||
bpy.ops.ed.undo_push()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.redo()
|
||||
|
||||
# Object-modified undo step.
|
||||
ob = bpy.data.objects["Cube"]
|
||||
assert (bpy.context.object == ob)
|
||||
ob.location.x += 1.0
|
||||
bpy.ops.ed.undo_push()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.redo()
|
||||
bpy.ops.ed.redo()
|
||||
|
||||
# Mesh-modified undo step.
|
||||
ob = bpy.data.objects["Cube"]
|
||||
assert (bpy.context.object == ob)
|
||||
ob.data.transform(mathutils.Matrix.Translation((1, 0, 0)))
|
||||
bpy.ops.ed.undo_push()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.redo()
|
||||
bpy.ops.ed.redo()
|
||||
bpy.ops.ed.redo()
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time, 'undo_stack_memory': getattr(bpy.app, "memory_usage_undo", lambda: 0)()}
|
||||
return result
|
||||
|
||||
|
||||
class BlendUndoMemfileHeavyGeometryTest(api.Test):
|
||||
def name(self):
|
||||
return "undo_memfile_heavy_mesh_geometry"
|
||||
|
||||
def category(self):
|
||||
return "undo"
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
result, _ = env.run_in_blender(_run_heavy_geometry, {}, ["--factory-startup"])
|
||||
return result
|
||||
|
||||
|
||||
# Validate performances when an extremely large amount of small independant blocks of data are present.
|
||||
# This is generating many IDProperties in an ID.
|
||||
def _run_many_bheads_and_pointers(args):
|
||||
import bpy
|
||||
import mathutils
|
||||
import time
|
||||
|
||||
num_props_per_level = args["num_props_per_level"]
|
||||
num_levels = args["num_levels"]
|
||||
|
||||
# Recursively generate idproperties containing other idproperties.
|
||||
def gen_idprops(id_prop_owner, num_props_per_level, num_levels, curr_level):
|
||||
if curr_level == num_levels:
|
||||
for i in range(num_props_per_level):
|
||||
id_prop_owner[str(i)] = i
|
||||
else:
|
||||
for i in range(num_props_per_level):
|
||||
id_prop_owner[str(i)] = {}
|
||||
gen_idprops(id_prop_owner[str(i)], num_props_per_level, num_levels, curr_level + 1)
|
||||
|
||||
ob = bpy.data.objects["Cube"]
|
||||
|
||||
# Generate many IDProps in the object.
|
||||
ob['test_idproperties'] = {}
|
||||
gen_idprops(ob['test_idproperties'], num_props_per_level, num_levels, 1)
|
||||
|
||||
start_time = time.time()
|
||||
# NOTE: The first undo push is necessary to be able to undo, since it creates the
|
||||
# initial state for memfile undo (it is not initialized by default in background mode).
|
||||
bpy.ops.ed.undo_push()
|
||||
|
||||
# Empty undo step.
|
||||
bpy.ops.ed.undo_push()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.redo()
|
||||
|
||||
# Object-modified undo step.
|
||||
ob = bpy.data.objects["Cube"]
|
||||
ob['test_idproperties_empty'] = {}
|
||||
bpy.ops.ed.undo_push()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.undo()
|
||||
bpy.ops.ed.redo()
|
||||
bpy.ops.ed.redo()
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
result = {'time': elapsed_time, 'undo_stack_memory': getattr(bpy.app, "memory_usage_undo", lambda: 0)()}
|
||||
return result
|
||||
|
||||
|
||||
class BlendUndoMemfileManyPointersTest(api.Test):
|
||||
def name(self):
|
||||
return "undo_memfile_1M_bheads_and_pointers"
|
||||
|
||||
def category(self):
|
||||
return "undo"
|
||||
|
||||
def run(self, env, device_id, gpu_backend):
|
||||
result, _ = env.run_in_blender(
|
||||
_run_many_bheads_and_pointers,
|
||||
# Will generate 100^3, i.e. 1M idprops.
|
||||
{"num_props_per_level": 100, "num_levels": 3},
|
||||
["--factory-startup"]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def generate(env):
|
||||
return [BlendUndoMemfileHeavyGeometryTest(), BlendUndoMemfileManyPointersTest()]
|
||||
Reference in New Issue
Block a user