Weekly benchmarks (#1758)

* support weekly benchmarks

* updated for perftest

* rem clipper

* other tests

* parse cmake output + use ember-benchmark-cases.json

* add only required STl files for ember tests

* remove ember tests+fix corrupted stl file

* shell script to py + minor changes

* mac-os

* more info of machine with linux fallback

* storage for weekly benchmarks

* rebase

* binary stl

* clean

* .

* fix ember phase capture and drop removed phases

- merge stderr into stdout in run_ember_case so man_bench diagnostic
  output is not silently dropped
- remove Assembly/Triangulation/Simplification/Sorting from
  INDEPENDENT_PHASES; their Timer::Print calls were removed in PR #1745
  and they always produced 0.0; only Intersect12 and Winding03 phases
  are still reported
- update build_ember_summary table to match the 4 remaining phases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert runner

* add frontend + trigger on publish + san extended test

* fix formatting

* TEMP: allow workflow_dispatch to exercise data-publish steps for testing

Not for merge - revert before this PR lands. Only relaxing the if: gate
so a manual run can validate the folded run: > block and sanitizer arg
passing reviewers asked about, without waiting for a scheduled/release run.

* Revert "TEMP: allow workflow_dispatch to exercise data-publish steps for testing"

This reverts commit e00c5f5784eefa27255681dce4105bd45be3daa0.

* Move weekly sanitizer lane to Linux, fix artifact path, derive compiler

- Move build_sanitizer/test_sanitizer in weekly_benchmark.yml from
  macos-15 to ubuntu-24.04, matching the existing PR-triggered sanitizer
  lane in manifold.yml, since nothing about ASan/UBSan requires macOS
  here. Drops the compiler overrides and macOS-only setup steps.
- Download sanitizer build artifacts into build/ instead of the
  workspace root, so manifold_test's baked-in RPATH to libsamples can
  actually resolve. It was crashing on load with
  "Library not loaded: @rpath/libsamples.3.dylib" because the artifact
  download flattened the build/ directory away.
- Capture the sanitizer build's own cmake configure log and derive
  SANITIZER_SUMMARY_COMPILER from it at runtime instead of hardcoding
  the string, reusing system_metadata.py's existing helpers.
- Trim set -euo pipefail to set -u in the data-publish steps, since
  GitHub Actions' default bash invocation already applies -e/pipefail.
- Remove the duplicate weeklybenchmark-test job from manifold.yml in
  favor of testing weekly_benchmark.yml directly via workflow_dispatch.

* Merge dashboard pages + remove dead JS fallbacks + gtest JSON output + simplify perf summary formatting + clean up .gitignore

* Stop forcing clang-18 for sanitizer builds, use runner default (gcc)

* small changes

* -Wno-maybe-uninitialized

* review changes

* dead code

* fix cmke read

* change flags

* remove redundant flags and schema version

* /dashboard route + remove upload route + change dead and retention changes

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Anshul
2026-08-02 17:14:38 +08:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 422ab6fcee
commit ff42ddc885
34 changed files with 1982 additions and 155 deletions
+2
View File
@@ -22,6 +22,8 @@ jobs:
mkdir public
mv ./samples ./public
mv ./docs ./public
# weekly benchmark dashboard, served at /dashboard
mv ./dashboard ./public
- name: Download manifoldCAD editor, examples and documentation
uses: dawidd6/action-download-artifact@v6
+5 -40
View File
@@ -420,46 +420,11 @@ jobs:
. -B build
ASAN_OPTIONS=detect_container_overflow=0 cmake --build build
build_sanitizer:
name: Sanitizer build (ASan+UBSan)
timeout-minutes: 30
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libgtest-dev libtbb-dev clang-18 cmake
version: 1
- name: Build (clang-18 + asan/ubsan)
run: bash ./scripts/ci/sanitizer_build.sh
- name: Upload sanitizer build artifacts
uses: actions/upload-artifact@v4
with:
name: sanitizer-build
path: build/
retention-days: 5
test_sanitizer:
name: Sanitizer (ASan+UBSan)
needs: build_sanitizer
timeout-minutes: 30
runs-on: ubuntu-24.04
env:
ASAN_OPTIONS: detect_container_overflow=0:strict_init_order=1
UBSAN_OPTIONS: print_stacktrace=1
SANITIZER_TEST_TIMEOUT_SEC: 1200
SANITIZER_SUBSET: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'extended' || 'core' }}
steps:
- uses: actions/checkout@v6
- name: Download sanitizer build artifacts
uses: actions/download-artifact@v4
with:
name: sanitizer-build
path: .
- name: Test sanitizer subset
shell: bash
run: bash ./scripts/ci/sanitizer_test.sh
sanitizer:
uses: ./.github/workflows/sanitizer.yml
with:
subset: core
retention-days: 1
build_mxe:
name: MXE (TBB:${{matrix.parallelization == 'ON'}})
+68
View File
@@ -0,0 +1,68 @@
name: Sanitizer
on:
workflow_call:
inputs:
subset:
description: Which gtest subset to run - core or extended.
type: string
required: true
retention-days:
description: >
Retention for the shared sanitizer build artifact. It only exists to
hand the build from the build job to the test job, so this is short.
type: number
default: 1
test-timeout-sec:
description: Per-run timeout applied inside sanitizer_test.sh.
type: number
default: 1200
outputs:
build-result:
description: Result of the sanitizer build job.
value: ${{ jobs.build_sanitizer.result }}
test-result:
description: Result of the sanitizer test job.
value: ${{ jobs.test_sanitizer.result }}
jobs:
build_sanitizer:
name: Sanitizer build (ASan+UBSan)
timeout-minutes: 30
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libgtest-dev cmake
version: 1
- name: Build (asan/ubsan)
run: ./scripts/ci/sanitizer_build.sh
- name: Upload sanitizer build artifacts
uses: actions/upload-artifact@v4
with:
name: sanitizer-build
path: build/
retention-days: ${{ inputs.retention-days }}
test_sanitizer:
name: Sanitizer ${{ inputs.subset }} subset (ASan+UBSan)
needs: build_sanitizer
timeout-minutes: 30
runs-on: ubuntu-24.04
env:
ASAN_OPTIONS: detect_container_overflow=0:strict_init_order=1
UBSAN_OPTIONS: print_stacktrace=1
SANITIZER_TEST_TIMEOUT_SEC: ${{ inputs.test-timeout-sec }}
SANITIZER_SUBSET: ${{ inputs.subset }}
steps:
- uses: actions/checkout@v6
- name: Download sanitizer build artifacts
uses: actions/download-artifact@v4
with:
name: sanitizer-build
path: build
- name: Test sanitizer subset
shell: bash
run: bash ./scripts/ci/sanitizer_test.sh
+77
View File
@@ -0,0 +1,77 @@
name: Weekly Benchmarks
on:
workflow_dispatch:
release:
types: [published]
schedule:
- cron: "0 2 * * 0"
permissions:
contents: write
concurrency:
group: weekly-benchmark
cancel-in-progress: false
jobs:
sanitizer:
uses: ./.github/workflows/sanitizer.yml
with:
subset: extended
retention-days: 1
weekly_benchmark:
name: Weekly benchmarks
needs: sanitizer
if: ${{ !cancelled() }}
timeout-minutes: 60
runs-on: macos-15
env:
WEEKLY_BENCHMARK_REPEATS: 5
WEEKLY_BENCHMARK_THREADS: 1
CMAKE_OSX_ARCHITECTURES: arm64
DATA_BRANCH: benchmark-data
RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }}
SANITIZER_SUMMARY_SUBSET: extended
SANITIZER_SUMMARY_RUNNER: ubuntu-24.04
SANITIZER_SUMMARY_OS: Linux
SANITIZER_BUILD_RESULT: ${{ needs.sanitizer.outputs.build-result }}
SANITIZER_TEST_RESULT: ${{ needs.sanitizer.outputs.test-result }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install brew packages
run: brew install googletest assimp tbb cmake
- name: Run weekly benchmarks
shell: bash
run: python3 ./scripts/run_weekly_benchmarks.py . ./bench/weekly "${WEEKLY_BENCHMARK_REPEATS}"
- name: Report weekly benchmarks
run: python3 ./scripts/report_weekly_benchmarks.py . ./bench/weekly "${WEEKLY_BENCHMARK_REPEATS}"
- name: Publish weekly benchmark data
if: github.event_name == 'release' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
shell: bash
run: |
# github-actions[bot]'s identity for committing with the built-in
# token, see https://github.com/actions/checkout#push-a-commit-using-the-built-in-token
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin "${DATA_BRANCH}"
git worktree add benchmark-data "origin/${DATA_BRANCH}"
python3 ./scripts/publish_weekly_benchmark_results.py \
./bench/weekly ./benchmark-data \
--release-tag "${RELEASE_TAG}" \
--sanitizer-subset "${SANITIZER_SUMMARY_SUBSET}" \
--sanitizer-build-result "${SANITIZER_BUILD_RESULT}" \
--sanitizer-test-result "${SANITIZER_TEST_RESULT}" \
--sanitizer-runner "${SANITIZER_SUMMARY_RUNNER}" \
--sanitizer-os "${SANITIZER_SUMMARY_OS}"
git -C benchmark-data add weekly
git -C benchmark-data commit -m "add weekly benchmark result ${GITHUB_RUN_ID}"
git -C benchmark-data push origin "HEAD:${DATA_BRANCH}"
+1 -1
View File
@@ -133,7 +133,7 @@ CMakeSettings.json
[._]sw[a-p]
# Benchmark stl files
extras/ember_tests/testfiles/raw_meshes
extras/ember_tests/testfiles/raw_meshes/*
#Eclipse project files
/.project
+116
View File
@@ -0,0 +1,116 @@
body.container {
max-width: 1800px;
margin: 0 auto;
padding: 28px 64px;
color: #222;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
a {
color: #2563c9;
}
.top-nav {
margin-bottom: 14px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 14px;
}
h1 {
margin: 10px 0 16px;
font-size: 26px;
}
h2 {
margin-top: 18px;
}
details {
margin-bottom: 12px;
}
summary {
cursor: pointer;
font-weight: 600;
}
.controls,
#scale-select-form {
margin: 8px 0 12px;
}
#scale-select-form label,
.controls label {
margin-right: 14px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.data-source,
#page-latest,
#status-line {
margin: 8px 0;
}
#page-latest p {
margin: 3px 0;
}
.top-nav a.active {
font-weight: 600;
color: inherit;
text-decoration: none;
}
.graphs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 30px;
margin-top: 8px;
}
.chart-box {
min-width: 0;
min-height: 420px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 8px;
}
th,
td {
border-bottom: 1px solid #ddd;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
}
#error-box {
padding: 10px;
border: 1px solid #c44;
background: #fff5f5;
color: #8a1f1f;
white-space: pre-wrap;
}
@media screen and (max-width: 900px) {
body.container {
padding: 18px;
}
.graphs {
grid-template-columns: 1fr;
}
}
+536
View File
@@ -0,0 +1,536 @@
'use strict';
const DEFAULT_DATA_ROOT =
'https://raw.githubusercontent.com/elalish/manifold/benchmark-data';
const CHARTS = [
{
element: 'perf-time-chart',
metric: 'perf_time',
title: 'perfTest size sweep time',
unit: 'Milliseconds',
valueSuffix: ' ms',
},
{
element: 'perf-rss-chart',
metric: 'perf_rss',
title: 'perfTest peak RSS',
unit: 'MB',
valueSuffix: ' MB',
},
{
element: 'ember-full-chart',
metric: 'ember_full',
title: 'Ember full phase time',
unit: 'Milliseconds',
valueSuffix: ' ms',
},
{
element: 'ember-phase-chart',
metric: 'ember_phase',
title: 'Ember individual phase time',
unit: 'Milliseconds',
valueSuffix: ' ms',
maxSeries: 16,
},
{
element: 'gtest-time-chart',
metric: 'gtest_time',
title: 'Existing gtest time',
unit: 'Milliseconds',
valueSuffix: ' ms',
},
];
const state = {
dataRoot: '',
branchRoot: '',
index: null,
records: [],
scale: 'linear',
};
function qs(id) {
return document.getElementById(id);
}
function setText(id, text) {
const element = qs(id);
if (element) {
element.textContent = text;
}
}
function setStatus(text) {
setText('status-text', text);
}
function setError(message) {
const box = qs('error-box');
if (!box) {
return;
}
if (!message) {
box.hidden = true;
box.textContent = '';
return;
}
box.hidden = false;
box.textContent = message;
}
function normalizeRoot(root) {
return (root || DEFAULT_DATA_ROOT).replace(/\/+$/, '');
}
function withNoCache(url) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}t=${Date.now()}`;
}
async function fetchJson(url) {
const response = await fetch(withNoCache(url), {cache: 'no-store'});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${url}`);
}
return response.json();
}
async function loadIndex(dataRoot) {
const branchRoot = normalizeRoot(dataRoot);
const weeklyRoot = `${branchRoot}/weekly`;
const indexUrl = `${weeklyRoot}/index.json`;
try {
const index = await fetchJson(indexUrl);
return {branchRoot, weeklyRoot, index};
} catch (error) {
throw new Error(`Unable to load benchmark index.\n${error.message}`);
}
}
function resultUrl(entry) {
// result_path is always a weekly/-prefixed path relative to branchRoot -
// see publish_weekly_benchmark_results.py's dated_rel construction.
return `${state.branchRoot}/${entry.result_path}`;
}
function shortSha(sha) {
return sha ? sha.slice(0, 8) : 'unknown';
}
function formatDate(raw) {
if (!raw) {
return 'unknown';
}
const date = new Date(raw);
if (Number.isNaN(date.getTime())) {
return raw;
}
return date.toISOString().slice(0, 10);
}
function runLabel(entry) {
const label = formatDate(entry.timestamp);
return entry.release_tag ? `${label} (${entry.release_tag})` : label;
}
function sanitizerSummary(entry) {
const sanitizer = entry.sanitizer;
if (!sanitizer) {
return '-';
}
const subset = sanitizer.subset || 'sanitizer';
const build = sanitizer.build_result || 'unknown';
const test = sanitizer.test_result || 'unknown';
return build === 'success' ? `${subset}: ${test}` :
`${subset}: build ${build}`;
}
function statKey(stat, suffix = '') {
return `${stat}${suffix}`;
}
function currentStat() {
return qs('stat-select')?.value || 'median';
}
function metricItems(record, metricId, stat) {
const suites = record.result.suites || {};
if (metricId === 'perf_time') {
return (suites.perf_size_sweep?.workloads || [])
.map((workload) => ({
name: workload.benchmark || `nTri=${workload.n_tri}`,
value: workload.time_ms?.[statKey(stat, '_ms')] ?? null,
}));
}
if (metricId === 'perf_rss') {
return (suites.perf_size_sweep?.workloads || [])
.map((workload) => ({
name: workload.benchmark || `nTri=${workload.n_tri}`,
value: workload.peak_rss_mb?.[stat] ?? null,
}));
}
if (metricId === 'ember_full') {
return (suites.weekly_ember_phase?.cases || [])
.map(
(caseItem) => ({
name: `case ${caseItem.case_index}`,
value: caseItem.full_phase_sum_ms?.[statKey(stat, '_ms')] ?? null,
}));
}
if (metricId === 'ember_phase') {
const items = [];
for (const caseItem of suites.weekly_ember_phase?.cases || []) {
for (const [phase, stats] of Object.entries(caseItem.phases || {})) {
items.push({
name: `case ${caseItem.case_index} / ${phase}`,
value: stats?.[statKey(stat, '_ms')] ?? null,
});
}
}
return items;
}
if (metricId === 'gtest_time') {
return (suites.existing_gtests?.tests || [])
.map((test) => ({
name: test.name,
value: test.time_ms?.[statKey(stat, '_ms')] ?? null,
}));
}
return [];
}
function buildSeries(metricId, stat, maxSeries) {
const categories = state.records.map((record) => runLabel(record.entry));
const names = new Set();
const byRun = state.records.map((record) => {
const map = new Map();
for (const item of metricItems(record, metricId, stat)) {
names.add(item.name);
map.set(item.name, item.value);
}
return map;
});
const latestMap = byRun[byRun.length - 1] || new Map();
const sortedNames = [...names].sort((a, b) => {
const bValue = latestMap.get(b) ?? -Infinity;
const aValue = latestMap.get(a) ?? -Infinity;
return bValue - aValue || a.localeCompare(b);
});
const visibleNames = sortedNames.slice(0, maxSeries || sortedNames.length);
const series = visibleNames.map((name) => ({
type: 'line',
name,
animation: false,
data: byRun.map((run) => {
const value = run.get(name) ?? null;
if (state.scale === 'log' &&
value !== null && value <= 0) {
return null;
}
return value;
}),
}));
return {categories, series, totalSeries: sortedNames.length};
}
function renderHighchartsChart(config) {
const stat = currentStat();
const {categories, series, totalSeries} = buildSeries(
config.metric,
stat,
config.maxSeries,
);
Highcharts.chart({
chart: {
renderTo: config.element,
zooming: {
type: 'xy',
},
type: 'line',
},
title: {
text: config.title,
},
subtitle: {
text: totalSeries > series.length ?
`Showing top ${series.length} of ${totalSeries} series by latest ${
stat}` :
`${stat} across ${categories.length} loaded runs`,
},
yAxis: {
title: {text: config.unit},
min: state.scale === 'linear' ? 0 : undefined,
type: state.scale === 'log' ? 'logarithmic' : undefined,
},
xAxis: {
categories,
title: {text: 'Run date'},
},
tooltip: {
shared: false,
valueSuffix: config.valueSuffix,
},
series,
});
}
function renderCharts() {
const chartsOnPage = CHARTS.filter((chart) => qs(chart.element));
if (!chartsOnPage.length) {
return;
}
if (!window.Highcharts) {
throw new Error('Highcharts was not loaded.');
}
for (const chart of chartsOnPage) {
renderHighchartsChart(chart);
}
}
function renderLatestMetadata() {
const latest = state.records[state.records.length - 1];
if (!latest) {
setText('latest-run', '-');
setText('latest-cpu', '-');
setText('latest-compiler', '-');
return;
}
const entry = latest.entry;
setText(
'latest-run',
`${formatDate(entry.timestamp)} / ${shortSha(entry.commit_sha)}`);
setText('latest-cpu', entry.cpu_model || 'unknown');
setText('latest-compiler', entry.compiler || 'unknown');
renderLatestTable(latest);
}
function renderLatestTable(latest) {
const container = qs('latest-metadata-table');
if (!container) {
return;
}
container.replaceChildren();
const entry = latest.entry;
const sanitizer = entry.sanitizer || {};
const rows = [
['Run ID', entry.run_id],
['Trigger', entry.trigger],
['Release tag', entry.release_tag || '-'],
['Timestamp', entry.timestamp],
['Commit', entry.commit_sha || 'unknown'],
['Workflow', entry.workflow || 'unknown'],
['Runner', entry.runner || 'unknown'],
['OS', entry.os || 'unknown'],
['Compiler', entry.compiler || 'unknown'],
['CPU', entry.cpu_model || 'unknown'],
['CPU count', entry.cpu_count || 'unknown'],
['Sanitizer subset', sanitizer.subset || '-'],
['Sanitizer build', sanitizer.build_result || '-'],
['Sanitizer test', sanitizer.test_result || '-'],
];
const table = document.createElement('table');
const tbody = document.createElement('tbody');
for (const [label, value] of rows) {
const tr = document.createElement('tr');
const th = document.createElement('th');
th.textContent = label;
const td = document.createElement('td');
td.textContent = value;
tr.append(th, td);
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
}
function renderRunTable(records) {
const container = qs('run-table');
if (!container) {
return;
}
container.replaceChildren();
const table = document.createElement('table');
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
for (const text
of ['date', 'release', 'commit', 'runner', 'cpu', 'sanitizer', 'run',
'json']) {
const th = document.createElement('th');
th.textContent = text;
headerRow.appendChild(th);
}
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const record of [...records].reverse()) {
const entry = record.entry;
const tr = document.createElement('tr');
for (const text
of [formatDate(entry.timestamp),
entry.release_tag || '-',
shortSha(entry.commit_sha),
entry.runner || 'unknown',
entry.cpu_model || 'unknown',
sanitizerSummary(entry),
]) {
const td = document.createElement('td');
td.textContent = text;
tr.appendChild(td);
}
const runCell = document.createElement('td');
if (entry.github_run_url) {
const runLink = document.createElement('a');
runLink.href = entry.github_run_url;
runLink.textContent = entry.run_id || 'run';
runCell.appendChild(runLink);
} else {
runCell.textContent = entry.run_id || '-';
}
tr.appendChild(runCell);
const jsonCell = document.createElement('td');
const jsonLink = document.createElement('a');
jsonLink.href = resultUrl(entry);
jsonLink.textContent = 'result.json';
jsonCell.appendChild(jsonLink);
tr.appendChild(jsonCell);
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
}
function renderDashboard() {
renderLatestMetadata();
renderCharts();
renderRunTable(state.records);
}
function selectedRunCount(total) {
const raw = qs('run-count')?.value || '10';
return raw === 'all' ? total : Math.min(Number(raw), total);
}
async function loadDashboard() {
setError('');
setStatus('Loading data');
setText('data-summary', 'Fetching benchmark index...');
const loadButton = qs('load-button');
if (loadButton) {
loadButton.disabled = true;
}
try {
const root = state.dataRoot || DEFAULT_DATA_ROOT;
const loaded = await loadIndex(root);
state.dataRoot = root;
state.branchRoot = loaded.branchRoot;
state.index = loaded.index;
const runs = [...loaded.index.runs].sort(
(a, b) => a.timestamp.localeCompare(b.timestamp),
);
const count = selectedRunCount(runs.length);
const selected = runs.slice(-count);
setText(
'data-summary',
`Fetching ${selected.length} result file${
selected.length === 1 ? '' : 's'}...`);
state.records = await Promise.all(
selected.map(async (entry) => ({
entry,
result: await fetchJson(resultUrl(entry)),
})),
);
setStatus('Loaded');
setText(
'data-summary',
`${runs.length} run${runs.length === 1 ? '' : 's'} in index, ${
state.records.length} loaded. Latest: ${
loaded.index.latest_run_id || 'unknown'}.`,
);
renderDashboard();
} catch (error) {
setStatus('Load failed');
setText('data-summary', 'The dashboard could not load benchmark data.');
setError(error.stack || error.message || String(error));
} finally {
if (loadButton) {
loadButton.disabled = false;
}
}
}
function handleScaleChange(event) {
const value = event.target.value;
if (value !== 'linear' && value !== 'log') {
console.error('Invalid scale');
return;
}
if (state.scale !== value) {
state.scale = value;
renderDashboard();
}
}
const TABS = ['graphs', 'latest', 'runs', 'help'];
function currentTab() {
const hash = window.location.hash.replace(/^#/, '');
return TABS.includes(hash) ? hash : 'graphs';
}
function showTab(tab) {
for (const name of TABS) {
const page = qs(`page-${name}`);
if (page) {
page.hidden = name !== tab;
}
}
for (const link of document.querySelectorAll('.top-nav a[data-tab]')) {
link.classList.toggle('active', link.dataset.tab === tab);
}
if (tab === 'graphs') {
renderCharts();
}
}
function init() {
const params = new URLSearchParams(window.location.search);
state.dataRoot = params.get('data') || DEFAULT_DATA_ROOT;
setText('data-root-label', state.dataRoot);
window.addEventListener('hashchange', () => showTab(currentTab()));
showTab(currentTab());
qs('load-button')?.addEventListener('click', loadDashboard);
qs('stat-select')?.addEventListener('change', renderDashboard);
qs('run-count')?.addEventListener('change', loadDashboard);
qs('linear-scale-input')?.addEventListener('input', handleScaleChange);
qs('log-scale-input')?.addEventListener('input', handleScaleChange);
loadDashboard();
}
document.addEventListener('DOMContentLoaded', init);
+118
View File
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Manifold weekly benchmarks</title>
<link rel="stylesheet" href="dashboard.css">
</head>
<body class="container">
<div class="top-nav">&gt; <a href="#graphs" data-tab="graphs">graphs</a>, <a href="#latest" data-tab="latest">latest</a>, <a href="#runs" data-tab="runs">runs</a>, <a href="#help" data-tab="help">help</a>.</div>
<div class="controls">
<label for="run-count">Runs</label>
<select id="run-count">
<option value="10">latest 10</option>
<option value="25">latest 25</option>
<option value="all">all</option>
</select>
<button id="load-button" type="button">Reload</button>
</div>
<div class="data-source"><strong>Data source:</strong> <code id="data-root-label">-</code></div>
<p id="status-line"><strong>Status:</strong> <span id="status-text">Loading data</span></p>
<pre id="error-box" hidden></pre>
<section id="page-graphs">
<h1>Manifold weekly benchmarks</h1>
<details open>
<summary>What data is in the dashboard?</summary>
<p>
The dashboard reads weekly benchmark JSON files from the <code>benchmark-data</code>
branch. It shows the <code>perfTest</code> size sweep, selected Ember phase
timings, and selected existing gtests. The charts use Highcharts line graphs,
matching the approach used by the rustc-perf dashboard.
</p>
</details>
<form id="scale-select-form">
<label for="linear-scale-input">
<input id="linear-scale-input" type="radio" name="scale-select" value="linear" checked>
Linear-scale
</label>
<label for="log-scale-input">
<input id="log-scale-input" type="radio" name="scale-select" value="log">
Log-scale
</label>
</form>
<div class="controls">
<label for="stat-select">Statistic</label>
<select id="stat-select">
<option value="median">median</option>
<option value="mean">mean</option>
<option value="min">min</option>
<option value="max">max</option>
</select>
</div>
<div class="graphs">
<div id="perf-time-chart" class="chart-box"></div>
<div id="perf-rss-chart" class="chart-box"></div>
<div id="ember-full-chart" class="chart-box"></div>
<div id="ember-phase-chart" class="chart-box"></div>
<div id="gtest-time-chart" class="chart-box"></div>
</div>
</section>
<section id="page-latest" hidden>
<h1>Latest weekly benchmark run</h1>
<p>Run: <span id="latest-run">-</span></p>
<p>CPU: <span id="latest-cpu">-</span></p>
<p>Compiler: <span id="latest-compiler">-</span></p>
<p id="data-summary">Waiting for benchmark index...</p>
<div id="latest-metadata-table"></div>
</section>
<section id="page-runs" hidden>
<h1>Weekly benchmark runs</h1>
<div id="run-table"></div>
</section>
<section id="page-help" hidden>
<h1>Manifold benchmark dashboard help</h1>
<details open>
<summary>What data is in the dashboard?</summary>
<p>
The dashboard reads weekly benchmark JSON files from the <code>benchmark-data</code>
branch. The default source is <code>https://raw.githubusercontent.com/elalish/manifold/benchmark-data</code>.
For local testing against a fork, append <code>?data=&lt;raw benchmark-data branch URL&gt;</code>
to the dashboard URL. The URL must point at the branch root (e.g.
<code>https://raw.githubusercontent.com/&lt;you&gt;/manifold/benchmark-data</code>),
not the <code>weekly/</code> subdirectory.
Release-triggered benchmark runs are stored in the same timeline and marked
with their release tag.
</p>
</details>
<h2>Tabs</h2>
<p><a href="#graphs">graphs</a> shows Highcharts line charts for weekly benchmark trends.</p>
<p><a href="#latest">latest</a> shows metadata for the latest loaded run.</p>
<p><a href="#runs">runs</a> lists loaded historical runs and links to raw JSON.</p>
<h2>Measured suites</h2>
<ul>
<li><code>perfTest</code> size sweep time and peak RSS.</li>
<li>Selected Ember phase timings.</li>
<li>Selected existing gtests.</li>
<li>Sanitizer subset summary status from the weekly workflow.</li>
<li>Release tags, when a run was triggered by a published release.</li>
</ul>
</section>
<script src="https://cdn.jsdelivr.net/npm/highcharts@12.4.0/highcharts.js"></script>
<script src="dashboard.js"></script>
</body>
</html>
+5 -9
View File
@@ -15,10 +15,11 @@ decompress and put the `raw_meshes` directory inside `testfiles`.
1. Download the dataset.
2. Build the binary with `MANIFOLD_PAR` and `ASSIMP_ENABLE` enabled. The
binary should be inside `build/extras` (relative to the project root).
3. Run `python do_ember_tests.py`. This will take 10 minutes to an hour
depending on your machine. At the end, a `benchmark.csv` file will be
generated.
4. Run `python analyze_ember_tests.py` to analyze the result.
3. Run the selected Ember benchmark cases through
`scripts/run_weekly_benchmarks.py`, or run `man_bench` directly with the
case arguments from `testfiles/ember-benchmark-cases.json`.
4. Run `python analyze_ember_tests.py` to analyze the full `benchmark.csv`
result.
## Files
@@ -30,13 +31,8 @@ decompress and put the `raw_meshes` directory inside `testfiles`.
file with the argument and timing information.
- `testfiles/test_file/ember-benchmark-cases.json`: JSON file describing each
test case.
- `do_ember_tests.py` reads the description of the files and transforms
from the supplemental material of the Ember paper, and executes
man_bench on each pair. This creates a benchmark.csv file (remember
to remove that file again if you want a clean new run of data).
- `analyze_ember_tests.py` reads the benchmark.csv file and calculates
statistics and does a plot.
- `bug-case.json` is a single test case that takes abnormally long.
- `m3max_benchmarks.csv` is the benchmarks file gotten by running on a
Macbook Pro M3 Max (12 performance cores, 4 efficiency cores).
-69
View File
@@ -1,69 +0,0 @@
import os
import json
import subprocess
import sys
import pathlib
ember_tests = pathlib.Path(__file__).parent
root = ember_tests.parent.parent
meshdir = ember_tests.joinpath("testfiles/raw_meshes")
testspecfile = ember_tests.joinpath("testfiles/ember-benchmark-cases.json")
man_bench = root.joinpath("build/extras/man_bench")
threads_cases = [1, 2, 4, 6, 8, 10, 12, 14, 16]
meshfiles = os.listdir(meshdir)
id_to_file = {}
for f in meshfiles:
base, ext = os.path.splitext(f)
if base and ext:
try:
baseid = int(base)
id_to_file[baseid] = f
except:
pass
def file_for_id(id):
if id in id_to_file:
file = id_to_file[id]
fullfile = meshdir.joinpath(file)
return fullfile
return None
with open(testspecfile, "r") as file:
spec_data = json.load(file)
for threads in threads_cases:
spec_index = 0
for spec in spec_data:
id1 = spec["id_a"]
id2 = spec["id_b"]
tr1 = spec["transform_a"]
tr2 = spec["transform_b"]
file1 = file_for_id(id1)
if not file1:
print("no test file for ", id1)
file2 = file_for_id(id2)
if not file2:
print("no test file for ", id2)
args = [man_bench, file1, file2]
for i in [1, 2]:
args.append("-t%d" % i)
tr = tr1 if i == 1 else tr2
for col in ["col0", "col1", "col2", "col3"]:
for row in ["x", "y", "z"]:
v = tr[col][row]
args.append("%f" % v)
if threads != 0:
args.append("--threads")
args.append("%d" % threads)
print("test", spec_index, "threads", threads)
cp = subprocess.run(args)
if cp.returncode != 0:
print("return code", cp.returncode)
sys.exit(1)
spec_index = spec_index + 1
+15 -1
View File
@@ -155,7 +155,7 @@ static MeshGL do_boolean(const std::string& file1, const std::string& file2,
static void usage() {
std::cout << "Usage: %s file1 file2 [-t1 <12 floats>] [-t2 <12 floats>] "
"[--threads <int>]\n";
"[--threads <int>] [--verbose <int>]\n";
}
int main(int argc, const char** argv) {
@@ -170,6 +170,7 @@ int main(int argc, const char** argv) {
mat3x4 transform1 = linalg::identity;
mat3x4 transform2 = linalg::identity;
int threads = 1000;
int manifold_verbose = 0;
if (argc > 3) {
int argi = 3;
while (argi < argc) {
@@ -201,12 +202,25 @@ int main(int argc, const char** argv) {
return 1;
}
argi += 2;
} else if (strcmp(argv[argi], "--verbose") == 0) {
if (argi + 1 >= argc) {
usage();
return 1;
}
try {
manifold_verbose = std::stoi(argv[argi + 1]);
} catch (...) {
usage();
return 1;
}
argi += 2;
} else {
usage();
return 1;
}
}
}
ManifoldParams().verbose = manifold_verbose;
if (verbose) {
std::cout << "files: " << file1 << ", " << file2 << "\n";
if (threads > 0) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Regular → Executable
+7 -7
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
#!/usr/bin/env -S bash -euo pipefail
# Only CXX flags are set: manifold is declared `project(manifold LANGUAGES CXX)`,
# so cmake never reads CMAKE_C_FLAGS.
#
# MANIFOLD_STRICT is deliberately OFF here - it adds -Werror, and this lane is
# for finding runtime memory/UB bugs, not for enforcing warnings.
cmake \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DBUILD_SHARED_LIBS=ON \
-DMANIFOLD_STRICT=ON \
-DMANIFOLD_PYBIND=OFF \
-DMANIFOLD_STRICT=OFF \
-DMANIFOLD_DEBUG=ON \
-DMANIFOLD_ASSERT=ON \
-DMANIFOLD_PAR=OFF \
-DCMAKE_C_COMPILER=clang-18 \
-DCMAKE_CXX_COMPILER=clang++-18 \
-DCMAKE_C_FLAGS="-fsanitize=address,undefined" \
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \
-DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address,undefined" \
+9 -22
View File
@@ -11,38 +11,25 @@ case "${SANITIZER_SUBSET}" in
SANITIZER_GTEST_FILTER="${SANITIZER_GTEST_FILTER_EXTENDED}"
;;
*)
echo "::warning::Unknown SANITIZER_SUBSET=${SANITIZER_SUBSET}, falling back to core."
SANITIZER_GTEST_FILTER="${SANITIZER_GTEST_FILTER_CORE}"
echo "::error::Unknown SANITIZER_SUBSET=${SANITIZER_SUBSET}"
exit 2
;;
esac
SANITIZER_TEST_BIN=""
for CANDIDATE in \
./build/test/manifold_test \
./test/manifold_test \
./sanitizer-build/test/manifold_test \
./sanitizer-build/build/test/manifold_test; do
if [ -f "${CANDIDATE}" ]; then
SANITIZER_TEST_BIN="${CANDIDATE}"
break
fi
done
if [ -z "${SANITIZER_TEST_BIN}" ]; then
SANITIZER_TEST_BIN="$(find . -type f -name manifold_test | head -n 1)"
fi
if [ -z "${SANITIZER_TEST_BIN}" ]; then
echo "::error::Could not find manifold_test after downloading sanitizer artifacts."
# The sanitizer build artifact is always downloaded into build/.
SANITIZER_TEST_BIN=./build/test/manifold_test
if [ ! -f "${SANITIZER_TEST_BIN}" ]; then
echo "::error::Could not find ${SANITIZER_TEST_BIN} after downloading sanitizer artifacts."
echo "::group::Artifact layout"
find . -maxdepth 4 -type d | sort
echo "::endgroup::"
exit 127
fi
chmod +x "${SANITIZER_TEST_BIN}" || true
# Artifact downloads do not preserve the executable bit.
chmod +x "${SANITIZER_TEST_BIN}"
set +e
timeout "${SANITIZER_TEST_TIMEOUT_SEC}" \
"${SANITIZER_TEST_BIN}" --gtest_filter="${SANITIZER_GTEST_FILTER}"
timeout "${SANITIZER_TEST_TIMEOUT_SEC}" "${SANITIZER_TEST_BIN}" --gtest_filter="${SANITIZER_GTEST_FILTER}"
TEST_RC="$?"
set -e
if [ "${TEST_RC}" -eq 124 ]; then
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env python3
import argparse
import json
import re
import sys
from pathlib import Path
from compare_pr_perf_guard import compute_stats
from compare_pr_perf_guard import parse_suite as parse_perf_run_suite
from system_metadata import resolve_metadata as resolve_system_metadata
SCHEMA_VERSION = "1.0.0"
EMBER_SUITE = "weekly_ember_phase"
PERF_SUITE = "perf_size_sweep"
GTEST_SUITE = "existing_gtests"
EMBER_CASE_PATTERN = re.compile(r"^### case\s+([0-9]+)(?:\s+\([0-9]+\s+vs\s+[0-9]+\))?")
PHASE_PATTERN = re.compile(r"^-+\s+([0-9]+)\s+ms for\s+(.*)$")
VERTS_PATTERN = re.compile(r"^[0-9]+\s+verts and\s+[0-9]+\s+tris$")
INDEPENDENT_PHASES = [
"Intersect12 P->Q",
"Intersect12 Q->P",
"Winding03 P",
"Winding03 Q",
]
def summarize(values: list[float], suffix: str = "") -> dict:
stats = compute_stats(values)
fields = {
name: stats[name]
for name in ("samples", "mean", "median", "sd", "min", "max")
}
return {
**{f"{name}{suffix}": value for name, value in fields.items()},
"n_runs": stats["n_runs"],
}
def run_files(suite_dir: Path) -> list[Path]:
files = sorted(suite_dir.glob("run*.txt"))
if not files:
raise RuntimeError(f"No run*.txt files found in {suite_dir}")
return files
def load_ember_specs(source_dir: Path) -> list[dict]:
spec_path = (
source_dir
/ "extras"
/ "ember_tests"
/ "testfiles"
/ "ember-benchmark-cases.json"
)
return json.loads(spec_path.read_text(encoding="utf-8"))
def parse_ember_run(run_path: Path, run_index: int) -> dict:
cases = []
current = None
accepting_phases = False
def finish_current() -> None:
if current is not None:
cases.append(current)
for raw_line in run_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip().lstrip("\ufeff")
case_match = EMBER_CASE_PATTERN.match(line)
if case_match:
finish_current()
current = {
"case_index": int(case_match.group(1)),
"phases_ms": {},
}
accepting_phases = True
continue
if current is None or not accepting_phases:
continue
phase_match = PHASE_PATTERN.match(line)
if phase_match:
phase = phase_match.group(2).strip()
if phase in INDEPENDENT_PHASES and phase not in current["phases_ms"]:
current["phases_ms"][phase] = float(phase_match.group(1))
continue
if VERTS_PATTERN.match(line.strip()):
accepting_phases = False
finish_current()
if not cases:
raise RuntimeError(f"No Ember benchmark cases found in {run_path}")
for case in cases:
for phase in INDEPENDENT_PHASES:
case["phases_ms"].setdefault(phase, 0.0)
return {"path": str(run_path), "run_index": run_index, "cases": cases}
def parse_ember_suite(suite_dir: Path, source_dir: Path) -> dict:
runs = [parse_ember_run(path, i + 1) for i, path in enumerate(run_files(suite_dir))]
ember_specs = load_ember_specs(source_dir)
case_order = [case["case_index"] for case in runs[0]["cases"]]
cases = []
for position, case_index in enumerate(case_order):
spec = ember_specs[case_index]
phase_samples = {phase: [] for phase in INDEPENDENT_PHASES}
full_phase_samples = []
intersect12_share_samples = []
for run in runs:
phases = run["cases"][position]["phases_ms"]
full_phase_sum = sum(phases[phase] for phase in INDEPENDENT_PHASES)
full_phase_samples.append(full_phase_sum)
intersect12_sum = phases["Intersect12 P->Q"] + phases["Intersect12 Q->P"]
intersect12_share_samples.append(
intersect12_sum / full_phase_sum if full_phase_sum else 0.0
)
for phase in INDEPENDENT_PHASES:
phase_samples[phase].append(phases[phase])
phase_metrics = {
phase: summarize(samples, "_ms") for phase, samples in phase_samples.items()
}
dominant_phase = max(
INDEPENDENT_PHASES, key=lambda phase: phase_metrics[phase]["mean_ms"]
)
cases.append(
{
"case_index": case_index,
"id_a": spec["id_a"],
"id_b": spec["id_b"],
"full_phase_sum_ms": summarize(full_phase_samples, "_ms"),
"intersect12_share": summarize(intersect12_share_samples),
"dominant_phase": dominant_phase,
"phases": phase_metrics,
}
)
return {
"type": "ember_phase",
"description": "Selected Ember boolean phase timing cases",
"phase_order": INDEPENDENT_PHASES,
"case_order": case_order,
"cases": cases,
"runs": runs,
}
def ntri_from_benchmark_key(benchmark: str) -> int | str:
prefix = "nTri="
if benchmark.startswith(prefix):
value = benchmark[len(prefix) :]
if value.isdigit():
return int(value)
return benchmark
def parse_perf_suite(suite_dir: Path) -> dict:
parsed = parse_perf_run_suite(suite_dir)
workload_order = [
ntri_from_benchmark_key(benchmark) for benchmark in parsed["benchmark_order"]
]
workloads = []
for benchmark, n_tri in zip(parsed["benchmark_order"], workload_order):
metrics = parsed["benchmarks"][benchmark]
time_ms_samples = [
sample * 1000.0 for sample in metrics["timing_sec"]["samples"]
]
workloads.append(
{
"benchmark": benchmark,
"n_tri": n_tri,
"time_ms": summarize(time_ms_samples, "_ms"),
"peak_rss_mb": metrics["peak_rss_mb"],
}
)
return {
"type": "perf_size_sweep",
"description": "perfTest sphere boolean size sweep",
"workload_order": workload_order,
"workloads": workloads,
"runs": parsed["runs"],
}
def parse_gtest_run(run_path: Path, run_index: int) -> dict:
# run_path is a gtest --gtest_output=json report; a passing test has no
# "failures" key, matching the old parser's "only count [ OK ] lines"
# behavior. time is a string in seconds, e.g. "0.024s" or "0s".
report = json.loads(run_path.read_text(encoding="utf-8-sig"))
tests = []
for testsuite in report.get("testsuites", []):
for test in testsuite.get("testsuite", []):
if "failures" in test:
continue
name = f"{test['classname']}.{test['name']}"
time_ms = float(test["time"].rstrip("s")) * 1000.0
tests.append({"name": name, "time_ms": time_ms})
return {"path": str(run_path), "run_index": run_index, "tests": tests}
def gtest_run_files(suite_dir: Path) -> list[Path]:
files = sorted(suite_dir.glob("run*.json"))
if not files:
raise RuntimeError(f"No run*.json files found in {suite_dir}")
return files
def parse_gtest_suite(suite_dir: Path) -> dict:
runs = [
parse_gtest_run(path, i + 1)
for i, path in enumerate(gtest_run_files(suite_dir))
]
test_order = [item["name"] for item in runs[0]["tests"]]
tests = []
for position, name in enumerate(test_order):
samples = [run["tests"][position]["time_ms"] for run in runs]
tests.append({"name": name, "time_ms": summarize(samples, "_ms")})
return {
"type": "existing_gtests",
"description": "Selected existing regression tests used as weekly benchmark signals",
"test_order": test_order,
"tests": tests,
"runs": runs,
}
def resolve_metadata(args: argparse.Namespace) -> dict:
metadata = resolve_system_metadata(args)
return {"schema_version": SCHEMA_VERSION, **metadata}
def build_ember_summary(suite: dict) -> list[str]:
lines = []
lines.append("#### Ember Phase Timings")
lines.append("")
lines.append(
"| Case | Dominant phase | Full mean (ms) | Intersect12 share | P->Q | Q->P | Winding P | Winding Q | Runs |"
)
lines.append("|---:|---|---:|---:|---:|---:|---:|---:|---:|")
for case in sorted(
suite["cases"],
key=lambda entry: entry["full_phase_sum_ms"]["mean_ms"],
reverse=True,
):
phases = case["phases"]
lines.append(
"| {case_index} | {dominant_phase} | {full:.2f} | {share:.3f} | "
"{p_to_q:.2f} | {q_to_p:.2f} | {winding_p:.2f} | "
"{winding_q:.2f} | {runs} |".format(
case_index=case["case_index"],
dominant_phase=case["dominant_phase"],
full=case["full_phase_sum_ms"]["mean_ms"],
share=case["intersect12_share"]["mean"],
p_to_q=phases["Intersect12 P->Q"]["mean_ms"],
q_to_p=phases["Intersect12 Q->P"]["mean_ms"],
winding_p=phases["Winding03 P"]["mean_ms"],
winding_q=phases["Winding03 Q"]["mean_ms"],
runs=case["full_phase_sum_ms"]["n_runs"],
)
)
lines.append("")
lines.append(
"Note: phase timings cover `Intersect12` and `Winding03` only; `Intersections (total)` is excluded from the denominator."
)
lines.append("")
return lines
def build_perf_summary(suite: dict) -> list[str]:
lines = []
lines.append("#### perfTest Size Sweep")
lines.append("")
lines.append(
"| nTri | Mean (ms) | Median (ms) | Min (ms) | Max (ms) | "
"Peak RSS mean (MB) | Peak RSS min (MB) | Peak RSS max (MB) | Runs |"
)
lines.append("|---:|---:|---:|---:|---:|---:|---:|---:|---:|")
for workload in suite["workloads"]:
timing = workload["time_ms"]
peak_rss = workload["peak_rss_mb"]
# peak_rss_mb can be None if RSS data was missing for every repeat
# (see compare_pr_perf_guard.py) - show N/A rather than crashing.
if peak_rss:
rss_mean = f"{peak_rss['mean']:.2f}"
rss_min = f"{peak_rss['min']:.2f}"
rss_max = f"{peak_rss['max']:.2f}"
else:
rss_mean = rss_min = rss_max = "N/A"
lines.append(
"| {n_tri} | {mean_ms:.2f} | {median_ms:.2f} | {min_ms:.2f} | "
"{max_ms:.2f} | {rss_mean} | {rss_min} | {rss_max} | "
"{n_runs} |".format(
n_tri=workload["n_tri"],
rss_mean=rss_mean,
rss_min=rss_min,
rss_max=rss_max,
**timing,
)
)
lines.append("")
return lines
def build_gtest_summary(suite: dict) -> list[str]:
lines = []
lines.append("#### Existing Regression Tests")
lines.append("")
lines.append("| Test | Mean (ms) | Median (ms) | Min (ms) | Max (ms) | Runs |")
lines.append("|---|---:|---:|---:|---:|---:|")
if not suite["tests"]:
lines.append("| No tests selected | 0 | 0 | 0 | 0 | 0 |")
for test in suite["tests"]:
timing = test["time_ms"]
lines.append(
"| {name} | {mean_ms:.2f} | {median_ms:.2f} | {min_ms:.2f} | "
"{max_ms:.2f} | {n_runs} |".format(
name=test["name"],
**timing,
)
)
lines.append("")
return lines
def build_summary(suites: dict, metadata: dict, repeats: int) -> str:
lines = []
lines.append("### Weekly Benchmarks")
lines.append("")
lines.append(f"Commit: `{metadata['commit_sha'] or 'unknown'}`")
lines.append(f"Runner: `{metadata['runner'] or 'unknown'}`")
lines.append(f"OS: `{metadata['os'] or 'unknown'}`")
lines.append(f"Compiler: `{metadata['compiler'] or 'unknown'}`")
lines.append(f"CPU: `{metadata['cpu_model'] or 'unknown'}`")
lines.append(f"CPU count: `{metadata['cpu_count'] or 'unknown'}`")
if metadata.get("cpu_model_identifier"):
lines.append(f"CPU model identifier: `{metadata['cpu_model_identifier']}`")
if metadata.get("cpu_physical_count"):
lines.append(f"CPU physical cores: `{metadata['cpu_physical_count']}`")
if metadata.get("cpu_performance_core_count") is not None:
lines.append(
f"CPU performance cores: `{metadata['cpu_performance_core_count']}`"
)
if metadata.get("cpu_efficiency_core_count") is not None:
lines.append(
f"CPU efficiency cores: `{metadata['cpu_efficiency_core_count']}`"
)
lines.append(f"Repeats: `{repeats}`")
lines.append("")
if EMBER_SUITE in suites:
lines.extend(build_ember_summary(suites[EMBER_SUITE]))
if PERF_SUITE in suites:
lines.extend(build_perf_summary(suites[PERF_SUITE]))
if GTEST_SUITE in suites:
lines.extend(build_gtest_summary(suites[GTEST_SUITE]))
return "\n".join(lines)
def parse_suites(root_dir: Path, source_dir: Path) -> dict:
suites = {}
ember_dir = root_dir / "ember_phase"
perf_dir = root_dir / "perf_size_sweep"
gtest_dir = root_dir / "existing_gtests"
if ember_dir.exists():
suites[EMBER_SUITE] = parse_ember_suite(ember_dir, source_dir)
if perf_dir.exists():
suites[PERF_SUITE] = parse_perf_suite(perf_dir)
if gtest_dir.exists():
suites[GTEST_SUITE] = parse_gtest_suite(gtest_dir)
if not suites:
raise RuntimeError(f"No weekly benchmark suites found in {root_dir}")
return suites
def main() -> int:
parser = argparse.ArgumentParser(description="Parse weekly benchmark runs.")
parser.add_argument("--suite-dir", required=True, type=Path)
parser.add_argument("--source-dir", default=Path("."), type=Path)
parser.add_argument("--markdown-out", required=True, type=Path)
parser.add_argument("--json-out", required=True, type=Path)
parser.add_argument("--repeats", required=True, type=int)
parser.add_argument("--commit-sha")
parser.add_argument("--workflow")
parser.add_argument("--runner")
parser.add_argument("--os-name")
parser.add_argument("--compiler")
parser.add_argument("--cpu-model")
parser.add_argument("--cpu-count", type=int)
args = parser.parse_args()
metadata = resolve_metadata(args)
suites = parse_suites(args.suite_dir, args.source_dir)
suite_names = list(suites.keys())
markdown = build_summary(suites, metadata, args.repeats)
payload = {
"metadata": metadata,
"config": {
"repeats": args.repeats,
"suites": suite_names,
},
"suites": suites,
}
args.markdown_out.write_text(markdown + "\n", encoding="utf-8")
args.json_out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
import argparse
import datetime
import json
import os
import shutil
import sys
from pathlib import Path
SCHEMA_VERSION = 1
# Metadata fields copied into each index entry. The index is a lightweight
# listing, so it deliberately carries a subset - the full metadata (cmake
# details, per-core CPU counts, ...) stays in the run's result.json.
INDEX_METADATA_FIELDS = (
"commit_sha",
"workflow",
"runner",
"os",
"compiler",
"cpu_model",
"cpu_count",
)
def utc_now() -> datetime.datetime:
return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
def parse_timestamp(raw: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(
datetime.timezone.utc
)
def json_dump(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
def github_run_url(run_id: str) -> str | None:
server = os.getenv("GITHUB_SERVER_URL")
repo = os.getenv("GITHUB_REPOSITORY")
if not server or not repo or not os.getenv("GITHUB_RUN_ID"):
return None
return f"{server}/{repo}/actions/runs/{run_id}"
def load_index(index_path: Path) -> dict:
if not index_path.exists():
return {"schema_version": SCHEMA_VERSION, "runs": []}
return json.loads(index_path.read_text(encoding="utf-8-sig"))
def optional_field(payload: dict, key: str, value: str | int | None) -> None:
if value not in (None, ""):
payload[key] = value
def sanitizer_summary(args: argparse.Namespace) -> dict | None:
summary = {}
optional_field(summary, "subset", args.sanitizer_subset)
optional_field(summary, "build_result", args.sanitizer_build_result)
optional_field(summary, "test_result", args.sanitizer_test_result)
optional_field(summary, "runner", args.sanitizer_runner)
optional_field(summary, "os", args.sanitizer_os)
return summary or None
def main() -> int:
parser = argparse.ArgumentParser(description="Publish weekly benchmark result files.")
parser.add_argument("suite_dir", type=Path)
parser.add_argument("data_dir", type=Path)
parser.add_argument("--release-tag")
parser.add_argument("--sanitizer-subset")
parser.add_argument("--sanitizer-build-result")
parser.add_argument("--sanitizer-test-result")
parser.add_argument("--sanitizer-runner")
parser.add_argument("--sanitizer-os")
args = parser.parse_args()
result_path = args.suite_dir / "result.json"
summary_path = args.suite_dir / "summary.md"
if not result_path.exists():
raise FileNotFoundError(f"Missing weekly benchmark result: {result_path}")
if not summary_path.exists():
raise FileNotFoundError(f"Missing weekly benchmark summary: {summary_path}")
result = json.loads(result_path.read_text(encoding="utf-8-sig"))
metadata = result["metadata"]
timestamp = parse_timestamp(metadata["timestamp"])
timestamp_iso = timestamp.isoformat().replace("+00:00", "Z")
run_id = os.getenv("GITHUB_RUN_ID") or timestamp.strftime("%Y%m%dT%H%M%SZ")
dated_rel = (
Path("weekly")
/ timestamp.strftime("%Y")
/ timestamp.strftime("%m")
/ timestamp.strftime("%d")
/ run_id
)
dated_dir = args.data_dir / dated_rel
dated_dir.mkdir(parents=True, exist_ok=True)
entry = {
"run_id": run_id,
"timestamp": timestamp_iso,
"date": timestamp.strftime("%Y-%m-%d"),
**{name: metadata[name] for name in INDEX_METADATA_FIELDS},
"result_path": str(dated_rel / "result.json").replace("\\", "/"),
"summary_path": str(dated_rel / "summary.md").replace("\\", "/"),
"github_run_url": github_run_url(run_id),
}
if args.release_tag:
entry["release_tag"] = args.release_tag
sanitizer = sanitizer_summary(args)
if sanitizer:
entry["sanitizer"] = sanitizer
entry["trigger"] = "release" if args.release_tag else "weekly"
result_dest = dated_dir / "result.json"
summary_dest = dated_dir / "summary.md"
stored_result = dict(result)
stored_result["storage"] = entry
json_dump(result_dest, stored_result)
shutil.copy2(summary_path, summary_dest)
json_dump(args.data_dir / "weekly" / "latest.json", stored_result)
index_path = args.data_dir / "weekly" / "index.json"
index = load_index(index_path)
runs = [run for run in index.get("runs", []) if str(run.get("run_id")) != run_id]
runs.append(entry)
runs.sort(key=lambda run: run.get("timestamp", ""))
index = {
"schema_version": SCHEMA_VERSION,
"generated_at": utc_now().isoformat().replace("+00:00", "Z"),
"latest_run_id": run_id,
"runs": runs,
}
json_dump(index_path, index)
print(f"Published weekly benchmark result to {result_dest}")
print(f"Updated {index_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
import parse_weekly_benchmarks as pwb
def print_file(path: Path) -> None:
sys.stdout.write(path.read_text(encoding="utf-8-sig"))
def raw_run_files(suite_dir: Path) -> list[Path]:
return sorted(
path
for path in suite_dir.rglob("run*.txt")
if path.is_file() and "build" not in path.relative_to(suite_dir).parts
)
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Report weekly benchmark results.")
parser.add_argument("source_dir", type=Path)
parser.add_argument("suite_dir", type=Path)
parser.add_argument("repeats", type=int)
args = parser.parse_args()
summary_path = args.suite_dir / "summary.md"
result_path = args.suite_dir / "result.json"
metadata = pwb.resolve_metadata(argparse.Namespace(suite_dir=args.suite_dir))
suites = pwb.parse_suites(args.suite_dir, args.source_dir)
markdown = pwb.build_summary(suites, metadata, args.repeats)
payload = {
"metadata": metadata,
"config": {"repeats": args.repeats, "suites": list(suites.keys())},
"suites": suites,
}
summary_path.write_text(markdown + "\n", encoding="utf-8")
result_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print("::group::Weekly benchmark summary")
print_file(summary_path)
print("::endgroup::")
print("::group::Weekly benchmark result.json")
print_file(result_path)
print("::endgroup::")
print("::group::Weekly benchmark raw outputs")
for run_file in raw_run_files(args.suite_dir):
print(f"--- {run_file} ---")
print_file(run_file)
print("::endgroup::")
step_summary = os.getenv("GITHUB_STEP_SUMMARY")
if step_summary:
with Path(step_summary).open("a", encoding="utf-8") as out:
out.write(summary_path.read_text(encoding="utf-8"))
out.write("\n")
out.write(
"Raw logs: open this step and expand `Weekly benchmark result.json` "
"and `Weekly benchmark raw outputs` groups.\n"
)
return 0
if __name__ == "__main__":
sys.exit(main())
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
import argparse
import csv
import json
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from benchmark_pr_perf_guard import NUM_SIZES as PERF_SIZE_COUNT
from benchmark_pr_perf_guard import run_measured_perf_size
DEFAULT_EMBER_CASES = "16 84 667 695 260 406 551 582"#valid cases 1 to 1000
DEFAULT_GTEST_FILTER = ":".join(
[
"Manifold.DeepChainDoesNotOverflowNumLeaves",
"Boolean.BatchBoolean",
"CrossSection.BatchBoolean",
"Polygon.Sponge4",
"Polygon.Zebra1",
"Polygon.Zebra3",
"ExecutionContextFromMeshGL.CancelConcurrent",
]
)
@dataclass(frozen=True)
class BuildContext:
source_dir: Path
out_dir: Path
repeats: int
build_dir: Path
@dataclass(frozen=True)
class BenchmarkSuite:
name: str
target: str
binary: str
run: object
def parse_cases(raw: str) -> list[int]:
cases = []
for part in raw.replace(",", " ").split():
cases.append(int(part))
if not cases:
raise ValueError("No weekly benchmark cases were provided")
return cases
def transform_args(prefix: str, transform: dict) -> list[str]:
args = [prefix]
for col in ("col0", "col1", "col2", "col3"):
for row in ("x", "y", "z"):
args.append(f"{float(transform[col][row]):f}")
return args
def run_command(
args: list[str], cwd: Path | None = None, log_path: Path | None = None
) -> subprocess.CompletedProcess:
print("+ " + " ".join(args))
sys.stdout.flush()
if log_path is None:
return subprocess.run(args, check=True, cwd=cwd)
log_path.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
args,
check=False,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
output = result.stdout
if output:
print(output, end="")
log_path.write_text("+ " + " ".join(args) + "\n" + output, encoding="utf-8")
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, args, output=result.stdout
)
return result
def configure_build(ctx: BuildContext) -> None:
cmake_args = [
"cmake",
"-S",
str(ctx.source_dir),
"-B",
str(ctx.build_dir),
"-DCMAKE_BUILD_TYPE=Release",
"-DMANIFOLD_STRICT=OFF",
"-DMANIFOLD_PAR=ON",
# TIMING gates the Boolean3 phase Timer output that the ember suite
# parses - without it those phases silently report zero.
"-DMANIFOLD_TIMING=ON",
# ASSIMP is needed to load the ember test meshes.
"-DASSIMP_ENABLE=ON",
]
if sys.platform == "darwin":
cmake_args.append(
f"-DCMAKE_OSX_ARCHITECTURES={os.getenv('CMAKE_OSX_ARCHITECTURES', 'arm64')}"
)
run_command(
cmake_args,
log_path=ctx.out_dir / "cmake_configure.log",
)
def build_targets(ctx: BuildContext, suites: list[BenchmarkSuite]) -> None:
targets = list(dict.fromkeys(suite.target for suite in suites))
run_command(["cmake", "--build", str(ctx.build_dir), "--target", *targets])
def find_binary(ctx: BuildContext, name: str) -> Path:
candidates = [
ctx.build_dir / "extras" / name,
ctx.build_dir / "test" / name,
ctx.build_dir / "bin" / name,
ctx.build_dir / name,
ctx.build_dir / "extras" / f"{name}.exe",
ctx.build_dir / "test" / f"{name}.exe",
ctx.build_dir / "bin" / f"{name}.exe",
ctx.build_dir / f"{name}.exe",
]
for candidate in candidates:
if candidate.exists() and candidate.is_file():
return candidate
joined = ", ".join(str(candidate) for candidate in candidates)
raise FileNotFoundError(f"{name} binary not found in expected paths: {joined}")
def get_mesh(mesh_id: int, mesh_dir: Path) -> Path:
path = mesh_dir / f"{mesh_id}.stl"
if path.exists() and path.stat().st_size > 0:
return path
raise FileNotFoundError(f"Required Ember benchmark mesh missing: {path}")
def load_ember_specs(spec_path: Path, cases: list[int]) -> list[dict]:
specs = json.loads(spec_path.read_text(encoding="utf-8"))
selected = []
for case_index in cases:
spec = dict(specs[case_index])
spec["case_index"] = case_index
selected.append(spec)
return selected
def write_ember_cases_metadata(out_dir: Path, specs: list[dict]) -> None:
with (out_dir / "cases.csv").open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["case_index", "id_a", "id_b"])
writer.writeheader()
for spec in specs:
writer.writerow(
{
"case_index": spec["case_index"],
"id_a": spec["id_a"],
"id_b": spec["id_b"],
}
)
def run_ember_case(
binary: Path, spec: dict, mesh_dir: Path, threads: int, verbose: int, cwd: Path
) -> str:
mesh_a = get_mesh(int(spec["id_a"]), mesh_dir)
mesh_b = get_mesh(int(spec["id_b"]), mesh_dir)
args = [
str(binary),
str(mesh_a),
str(mesh_b),
*transform_args("-t1", spec["transform_a"]),
*transform_args("-t2", spec["transform_b"]),
"--threads",
str(threads),
"--verbose",
str(verbose),
]
result = subprocess.run(
args, check=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=cwd,
)
return result.stdout
def run_ember_suite(ctx: BuildContext, binary: Path) -> None:
out_dir = ctx.out_dir / "ember_phase"
mesh_dir = (
ctx.source_dir / "extras" / "ember_tests" / "testfiles" / "raw_meshes"
)
spec_file = (
ctx.source_dir
/ "extras"
/ "ember_tests"
/ "testfiles"
/ "ember-benchmark-cases.json"
)
cases = parse_cases(os.getenv("WEEKLY_BENCHMARK_CASES", DEFAULT_EMBER_CASES))
threads = int(os.getenv("WEEKLY_BENCHMARK_THREADS", "1"))
verbose = int(os.getenv("WEEKLY_BENCHMARK_VERBOSE", "2"))
specs = load_ember_specs(spec_file, cases)
out_dir.mkdir(parents=True, exist_ok=True)
write_ember_cases_metadata(out_dir, specs)
for run_index in range(1, ctx.repeats + 1):
run_file = out_dir / f"run{run_index}.txt"
benchmark_csv = out_dir / "benchmark.csv"
benchmark_csv.unlink(missing_ok=True)
with run_file.open("w", encoding="utf-8") as out:
for spec in specs:
out.write(f"### case {spec['case_index']}\n")
stdout = run_ember_case(
binary.resolve(),
spec,
mesh_dir.resolve(),
threads,
verbose,
out_dir.resolve(),
)
out.write(stdout)
if not stdout.endswith("\n"):
out.write("\n")
if benchmark_csv.exists():
benchmark_csv.replace(out_dir / f"benchmark_run{run_index}.csv")
print(f"completed Ember benchmark run {run_index}/{ctx.repeats}")
sys.stdout.flush()
def run_perf_size_sweep_suite(ctx: BuildContext, binary: Path) -> None:
out_dir = ctx.out_dir / "perf_size_sweep"
out_dir.mkdir(parents=True, exist_ok=True)
for run_index in range(1, ctx.repeats + 1):
run_file = out_dir / f"run{run_index}.txt"
with run_file.open("w", encoding="utf-8") as handle:
for size_index in range(PERF_SIZE_COUNT):
block, status = run_measured_perf_size(binary, size_index)
handle.write(f"### perfTest size_index={size_index}\n{block}\n\n")
handle.flush()
if status != 0:
raise RuntimeError(
f"perfTest exited with status {status} "
f"(run {run_index}, size_index={size_index}, dir={out_dir})"
)
print(f"completed perfTest run {run_index}/{ctx.repeats}")
sys.stdout.flush()
def run_existing_gtests_suite(ctx: BuildContext, binary: Path) -> None:
out_dir = ctx.out_dir / "existing_gtests"
out_dir.mkdir(parents=True, exist_ok=True)
gtest_filter = os.getenv("WEEKLY_BENCHMARK_GTEST_FILTER", DEFAULT_GTEST_FILTER)
for run_index in range(1, ctx.repeats + 1):
# gtest's --gtest_output=json emits structured per-test results
# directly, so parse_weekly_benchmarks.py doesn't need to regex the
# console text; relative path avoids Windows drive-letter colons
# being misread as the format:path separator.
json_name = f"run{run_index}.json"
result = subprocess.run(
[
str(binary),
f"--gtest_filter={gtest_filter}",
f"--gtest_output=json:{json_name}",
],
cwd=out_dir,
check=True,
capture_output=True,
text=True,
)
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
(out_dir / f"run{run_index}.txt").write_text(result.stdout, encoding="utf-8")
print(f"completed existing gtest run {run_index}/{ctx.repeats}")
sys.stdout.flush()
# Add future benchmark suites here.
# emits run<N>.txt files that parse_weekly_benchmarks.py can collect.
SUITES = [
BenchmarkSuite("ember_phase", "man_bench", "man_bench", run_ember_suite),
BenchmarkSuite(
"perf_size_sweep", "perfTest", "perfTest", run_perf_size_sweep_suite
),
BenchmarkSuite(
"existing_gtests", "manifold_test", "manifold_test", run_existing_gtests_suite
),
]
def main() -> int:
parser = argparse.ArgumentParser(description="Run weekly benchmark suites.")
parser.add_argument("source_dir", type=Path)
parser.add_argument("out_dir", type=Path)
parser.add_argument("repeats", type=int)
args = parser.parse_args()
ctx = BuildContext(
source_dir=args.source_dir.resolve(),
out_dir=args.out_dir.resolve(),
repeats=args.repeats,
build_dir=(args.out_dir / "build").resolve(),
)
ctx.out_dir.mkdir(parents=True, exist_ok=True)
configure_build(ctx)
build_targets(ctx, SUITES)
for suite in SUITES:
print(f"::group::Run weekly benchmark suite: {suite.name}")
binary = find_binary(ctx, suite.binary)
suite.run(ctx, binary)
print("::endgroup::")
return 0
if __name__ == "__main__":
sys.exit(main())
+45 -6
View File
@@ -3,9 +3,39 @@ import argparse
import datetime
import os
import platform
import re
import subprocess
from pathlib import Path
CMAKE_SUMMARY_PATTERN = re.compile(r"^--\s+([A-Z0-9_]+):\s*(.*)$")
def parse_cmake_configure_log(log_path: Path) -> dict:
if not log_path.exists():
return {}
values = {}
for line in log_path.read_text(encoding="utf-8", errors="ignore").splitlines():
match = CMAKE_SUMMARY_PATTERN.match(line.strip())
if match:
values[match.group(1)] = match.group(2).strip()
return {
"version": values.get("CMAKE_VERSION"),
"generator": values.get("CMAKE_GENERATOR"),
"build_type": values.get("CMAKE_BUILD_TYPE"),
"cxx_compiler_id": values.get("CMAKE_CXX_COMPILER_ID"),
"cxx_compiler_version": values.get("CMAKE_CXX_COMPILER_VERSION"),
}
def cmake_compiler(cmake: dict) -> str | None:
compiler_id = cmake.get("cxx_compiler_id")
compiler_version = cmake.get("cxx_compiler_version")
if compiler_id and compiler_version:
return f"{compiler_id} {compiler_version}"
return compiler_id or compiler_version
def detect_compiler() -> str | None:
# best compiler fingerprint for metadata
@@ -50,6 +80,8 @@ def int_or_none(value: str | None) -> int | None:
def default_cpu_model() -> str | None:
# /proc/cpuinfo gives a better CPU name than platform.processor()
# on GitHub-hosted Ubuntu runners.
cpuinfo = Path("/proc/cpuinfo")
if not cpuinfo.exists():
return platform.processor() or None
@@ -94,14 +126,21 @@ def cpu_details() -> dict:
def resolve_metadata(args: argparse.Namespace) -> dict:
# resolve metadata from args first, then GitHub env vars
timestamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
suite_dir = getattr(args, "suite_dir", None)
cmake = parse_cmake_configure_log(suite_dir / "cmake_configure.log") if suite_dir else {}
cpu = cpu_details()
cpu_count = getattr(args, "cpu_count", None) or cpu["logical_count"]
return {
"commit_sha": args.commit_sha or os.getenv("GITHUB_SHA"),
"workflow": args.workflow or os.getenv("GITHUB_WORKFLOW"),
"runner": args.runner or os.getenv("RUNNER_NAME"),
"os": args.os_name or os.getenv("RUNNER_OS"),
"compiler": args.compiler or detect_compiler(),
"cpu_model": cpu["model"],
"commit_sha": getattr(args, "commit_sha", None) or os.getenv("GITHUB_SHA"),
"workflow": getattr(args, "workflow", None) or os.getenv("GITHUB_WORKFLOW"),
"runner": getattr(args, "runner", None) or os.getenv("RUNNER_NAME"),
"os": getattr(args, "os_name", None) or os.getenv("RUNNER_OS"),
"compiler": getattr(args, "compiler", None)
or cmake_compiler(cmake)
or detect_compiler(),
"cmake": cmake,
"cpu_model": getattr(args, "cpu_model", None) or cpu["model"],
"cpu_count": cpu_count,
"cpu_brand": cpu["brand"],
"cpu_model_identifier": cpu["model_identifier"],
"cpu_arch": cpu["arch"],