Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View 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

View 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

View 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

View 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]

View 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]

View 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

View 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)

View 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>

View 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)

View 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