Add offline frontend and backend benchmarks
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# LayerForge offline benchmarks
|
||||
|
||||
These benchmarks are deliberately separate from the functional test suite. They
|
||||
measure the expensive data and pixel paths without starting ComfyUI or
|
||||
downloading a model.
|
||||
|
||||
## Frontend JavaScript
|
||||
|
||||
This measures the real LayerForge mask pixel helpers, layer snapshot cloning,
|
||||
and state signatures:
|
||||
|
||||
```powershell
|
||||
npm run benchmark -- --sizes 512,1024,2048 --layer-counts 1,4,8,16 --iterations 5
|
||||
```
|
||||
|
||||
Use `--format json` for machine-readable output. The Node benchmark does not
|
||||
pretend that canvas stubs represent real rasterization; use the browser harness
|
||||
for that part.
|
||||
|
||||
## Backend Python
|
||||
|
||||
Run it with the embedded Python used by the active Easy ComfyUI installation:
|
||||
|
||||
```powershell
|
||||
rtk proxy E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\python_embeded\python.exe benchmarks\backend.py --sizes 512,1024,2048 --iterations 5
|
||||
```
|
||||
|
||||
The script measures tensor/data-URL conversion, per-sample and batch PNG
|
||||
serialization, plus preprocessing/postprocessing around a fake RMBG model. It
|
||||
never loads a checkpoint. Set `LAYERFORGE_COMFY_ROOT` if the active ComfyUI
|
||||
root differs from the project default.
|
||||
|
||||
## Real browser Canvas 2D and PNG export
|
||||
|
||||
The browser harness measures representative compositing for 1, 4, 8, and 16
|
||||
layers, crop/blend/mask variants, PNG export, and the actual LayerForge distance
|
||||
transform implementation. It is not a ComfyUI workflow test; it uses only a
|
||||
local browser Canvas implementation.
|
||||
|
||||
Start a static server with the repository explicitly selected as its document
|
||||
root. This works even when the terminal is currently in another directory:
|
||||
|
||||
```powershell
|
||||
rtk proxy E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\python_embeded\python.exe -m http.server 8765 --directory E:\AI\AI\ComfyUI\ComfyUI_Windows_portable\ComfyUI\custom_nodes\Comfyui-LayerForge
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8765/benchmarks/browser.html
|
||||
```
|
||||
|
||||
For the larger matrix, set the size field to `512,1024,2048`. Keep the same
|
||||
browser, zoom, hardware acceleration setting, and iteration count when comparing
|
||||
two revisions. Benchmark output is printed in the page and is not written to the
|
||||
repository.
|
||||
|
||||
## Interpreting results
|
||||
|
||||
Compare median and p95 on the same machine. Warm-up samples are discarded. A
|
||||
browser render median above roughly 16 ms is likely to affect 60 FPS interaction;
|
||||
PNG export is a separate operation and should not be confused with the per-frame
|
||||
render budget. Model inference is intentionally excluded from the fake matting
|
||||
measurement and must be benchmarked separately with a specific checkpoint and
|
||||
device.
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Offline LayerForge backend benchmark.
|
||||
|
||||
This script measures tensor/image conversion and adapter overhead only. It does
|
||||
not start ComfyUI, download models, or run a real matting checkpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_COMFY_ROOT = Path(r"E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\ComfyUI")
|
||||
|
||||
|
||||
def _install_import_paths() -> Path:
|
||||
comfy_root = Path(os.environ.get("LAYERFORGE_COMFY_ROOT", DEFAULT_COMFY_ROOT))
|
||||
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||
if comfy_root.exists():
|
||||
sys.path.insert(0, str(comfy_root))
|
||||
return comfy_root
|
||||
|
||||
|
||||
def _parse_positive_list(value: str) -> list[int]:
|
||||
result = [int(item.strip()) for item in value.split(",") if item.strip()]
|
||||
if not result or any(item <= 0 for item in result):
|
||||
raise argparse.ArgumentTypeError(f"Expected positive integers, got: {value}")
|
||||
return result
|
||||
|
||||
|
||||
def _percentile(sorted_values: list[float], fraction: float) -> float:
|
||||
index = min(len(sorted_values) - 1, max(0, math.ceil(len(sorted_values) * fraction) - 1))
|
||||
return sorted_values[index]
|
||||
|
||||
|
||||
def _measure(operation, iterations: int) -> dict[str, float]:
|
||||
for _ in range(min(2, iterations)):
|
||||
operation()
|
||||
|
||||
samples = []
|
||||
for _ in range(iterations):
|
||||
started_at = time.perf_counter()
|
||||
operation()
|
||||
samples.append((time.perf_counter() - started_at) * 1000)
|
||||
|
||||
samples.sort()
|
||||
return {
|
||||
"median_ms": _percentile(samples, 0.5),
|
||||
"p95_ms": _percentile(samples, 0.95),
|
||||
"min_ms": samples[0],
|
||||
"max_ms": samples[-1],
|
||||
}
|
||||
|
||||
|
||||
def _rounded(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _rounded(item) for key, item in value.items()}
|
||||
if isinstance(value, float):
|
||||
return round(value, 3)
|
||||
return value
|
||||
|
||||
|
||||
def _run_conversion_benchmarks(torch, image_utils, node_module, serialization, sizes, iterations):
|
||||
from PIL import Image
|
||||
|
||||
output = {}
|
||||
for height, width in sizes:
|
||||
tensor = torch.rand((1, 3, height, width), dtype=torch.float32)
|
||||
encoded = image_utils.convert_tensor_to_base64(tensor)
|
||||
sample = tensor[0].permute(1, 2, 0)
|
||||
mask_tensor = torch.rand((height, width), dtype=torch.float32)
|
||||
|
||||
def serialize_mask():
|
||||
mask_array = (mask_tensor.cpu().numpy() * 255).astype("uint8")
|
||||
return serialization.pil_to_data_url(Image.fromarray(mask_array, "L"))
|
||||
|
||||
output[f"{height}x{width}"] = {
|
||||
"tensor_to_base64": _measure(
|
||||
lambda: image_utils.convert_tensor_to_base64(tensor), iterations
|
||||
),
|
||||
"base64_to_tensor": _measure(
|
||||
lambda: image_utils.convert_base64_to_tensor(encoded), iterations
|
||||
),
|
||||
"node_serialize_rgb_sample": _measure(
|
||||
lambda: node_module.LayerForgeNode._serialize_rgb_tensor_sample(sample), iterations
|
||||
),
|
||||
"mask_tensor_to_base64": _measure(serialize_mask, iterations),
|
||||
"encoded_bytes": len(encoded.encode("ascii")),
|
||||
}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _run_batch_benchmarks(torch, node_module, batch_size: int, batch_counts, iterations):
|
||||
output = {}
|
||||
for batch_count in batch_counts:
|
||||
batch = torch.rand((batch_count, batch_size, batch_size, 3), dtype=torch.float32)
|
||||
|
||||
def serialize_batch():
|
||||
return [
|
||||
node_module.LayerForgeNode._serialize_rgb_tensor_sample(batch[index])
|
||||
for index in range(batch_count)
|
||||
]
|
||||
|
||||
stats = _measure(serialize_batch, iterations)
|
||||
output[str(batch_count)] = {
|
||||
**stats,
|
||||
"batch_size": batch_size,
|
||||
"per_sample_median_ms": stats["median_ms"] / batch_count,
|
||||
}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _run_fake_matting_benchmarks(torch, rmbg_module, sizes, iterations):
|
||||
class FakeModel:
|
||||
def __call__(self, image):
|
||||
return torch.zeros((image.shape[0], 1, 256, 256), device=image.device)
|
||||
|
||||
adapter = rmbg_module.RMBG2Model(FakeModel(), torch.device("cpu"))
|
||||
output = {}
|
||||
for height, width in sizes:
|
||||
image = torch.rand((1, height, width, 3), dtype=torch.float32)
|
||||
output[f"{height}x{width}"] = _measure(
|
||||
lambda: adapter.encode_image(image), iterations
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sizes", type=_parse_positive_list, default=[512, 1024, 2048])
|
||||
parser.add_argument("--iterations", type=int, default=5)
|
||||
parser.add_argument("--batch-size", type=int, default=512)
|
||||
parser.add_argument("--batch-counts", type=_parse_positive_list, default=[1, 4, 8, 16])
|
||||
parser.add_argument("--skip-matting", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.iterations < 1 or args.batch_size < 1:
|
||||
parser.error("--iterations and --batch-size must be positive")
|
||||
|
||||
comfy_root = _install_import_paths()
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
from python import image_utils
|
||||
from python import image_serialization as serialization
|
||||
from python import node as node_module
|
||||
except Exception as error:
|
||||
print(
|
||||
"Unable to import the LayerForge runtime. Set LAYERFORGE_COMFY_ROOT "
|
||||
f"to the active ComfyUI root. Current root: {comfy_root}\n{error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
sizes = [(size, size) for size in args.sizes]
|
||||
report = {
|
||||
"benchmark": "layerforge-backend-offline",
|
||||
"runtime": {
|
||||
"python": sys.version.split()[0],
|
||||
"torch": torch.__version__,
|
||||
"torch_threads": torch.get_num_threads(),
|
||||
"device": "cpu",
|
||||
"comfy_root": str(comfy_root),
|
||||
},
|
||||
"config": {
|
||||
"sizes": args.sizes,
|
||||
"iterations": args.iterations,
|
||||
"batch_size": args.batch_size,
|
||||
"batch_counts": args.batch_counts,
|
||||
},
|
||||
"image_conversion": _run_conversion_benchmarks(
|
||||
torch, image_utils, node_module, serialization, sizes, args.iterations
|
||||
),
|
||||
"batch_serialization": _run_batch_benchmarks(
|
||||
torch, node_module, args.batch_size, args.batch_counts, args.iterations
|
||||
),
|
||||
"skipped": [],
|
||||
}
|
||||
|
||||
if args.skip_matting:
|
||||
report["skipped"].append({
|
||||
"benchmark": "fake_matting_adapter",
|
||||
"reason": "Disabled with --skip-matting",
|
||||
})
|
||||
else:
|
||||
try:
|
||||
from python.matting.backends import rmbg as rmbg_module
|
||||
|
||||
report["fake_matting_adapter"] = _run_fake_matting_benchmarks(
|
||||
torch, rmbg_module, sizes, args.iterations
|
||||
)
|
||||
except Exception as error:
|
||||
report["skipped"].append({
|
||||
"benchmark": "fake_matting_adapter",
|
||||
"reason": f"Adapter import failed: {error}",
|
||||
})
|
||||
|
||||
print(json.dumps(_rounded(report), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,394 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LayerForge Canvas Benchmark</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #1b1d21;
|
||||
color: #f0f2f5;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #687080;
|
||||
border-radius: 5px;
|
||||
background: #2c6eac;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 190px;
|
||||
margin: 0 6px;
|
||||
padding: 5px;
|
||||
color: inherit;
|
||||
background: #242830;
|
||||
border: 1px solid #687080;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#status {
|
||||
min-height: 1.4em;
|
||||
margin: 16px 0;
|
||||
color: #b7c4d8;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #424852;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th:first-child, td:first-child,
|
||||
th:nth-child(2), td:nth-child(2),
|
||||
th:nth-child(3), td:nth-child(3) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #111317;
|
||||
border: 1px solid #424852;
|
||||
}
|
||||
|
||||
.note {
|
||||
max-width: 900px;
|
||||
color: #b7c4d8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LayerForge Canvas benchmark</h1>
|
||||
<p class="note">
|
||||
This is a ComfyUI-free browser benchmark. It measures representative Canvas 2D
|
||||
compositing and PNG export, plus the real LayerForge distance-transform helpers.
|
||||
It is intentionally not a replacement for a ComfyUI workflow smoke test.
|
||||
</p>
|
||||
<p>
|
||||
<label>Sizes <input id="sizes" value="512,1024"></label>
|
||||
<label>Layer counts <input id="layerCounts" value="1,4,8,16"></label>
|
||||
<label>Iterations <input id="iterations" value="3" type="number" min="1" max="20"></label>
|
||||
<button id="run">Run benchmark</button>
|
||||
</p>
|
||||
<div id="status">Ready.</div>
|
||||
<div id="results"></div>
|
||||
<pre id="json"></pre>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
calculateDistanceTransform,
|
||||
rasterizeDistanceFieldMask,
|
||||
} from '../js/mask/mask_pixel_utils.js';
|
||||
|
||||
const variants = ['plain', 'crop', 'blend', 'mask'];
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
const statusElement = document.getElementById('status');
|
||||
const resultsElement = document.getElementById('results');
|
||||
const jsonElement = document.getElementById('json');
|
||||
const runButton = document.getElementById('run');
|
||||
|
||||
function parseList(value) {
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => Number.parseInt(item.trim(), 10))
|
||||
.filter(Number.isFinite)
|
||||
.filter(item => item > 0);
|
||||
}
|
||||
|
||||
function percentile(sortedValues, fraction) {
|
||||
const index = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.max(0, Math.ceil(sortedValues.length * fraction) - 1),
|
||||
);
|
||||
return sortedValues[index];
|
||||
}
|
||||
|
||||
async function measure(operation, iterations) {
|
||||
for (let index = 0; index < Math.min(2, iterations); index += 1) {
|
||||
await operation();
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const startedAt = performance.now();
|
||||
await operation();
|
||||
samples.push(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
samples.sort((left, right) => left - right);
|
||||
return {
|
||||
medianMs: percentile(samples, 0.5),
|
||||
p95Ms: percentile(samples, 0.95),
|
||||
minMs: samples[0],
|
||||
maxMs: samples[samples.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function makeLayerCanvas(size, index) {
|
||||
const layerSize = Math.max(64, Math.round(size * 0.66));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = layerSize;
|
||||
canvas.height = layerSize;
|
||||
const context = canvas.getContext('2d');
|
||||
const gradient = context.createLinearGradient(0, 0, layerSize, layerSize);
|
||||
const hue = (index * 47) % 360;
|
||||
gradient.addColorStop(0, `hsla(${hue}, 80%, 62%, 0.9)`);
|
||||
gradient.addColorStop(1, `hsla(${(hue + 90) % 360}, 80%, 30%, 0.9)`);
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, layerSize, layerSize);
|
||||
context.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
||||
context.beginPath();
|
||||
context.arc(layerSize * 0.5, layerSize * 0.5, layerSize * 0.32, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = layerSize;
|
||||
tempCanvas.height = layerSize;
|
||||
const tempContext = tempCanvas.getContext('2d');
|
||||
|
||||
const blendMask = document.createElement('canvas');
|
||||
blendMask.width = layerSize;
|
||||
blendMask.height = layerSize;
|
||||
const maskContext = blendMask.getContext('2d');
|
||||
const radial = maskContext.createRadialGradient(
|
||||
layerSize / 2,
|
||||
layerSize / 2,
|
||||
layerSize * 0.18,
|
||||
layerSize / 2,
|
||||
layerSize / 2,
|
||||
layerSize * 0.52,
|
||||
);
|
||||
radial.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
radial.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
maskContext.fillStyle = radial;
|
||||
maskContext.fillRect(0, 0, layerSize, layerSize);
|
||||
|
||||
return { canvas, tempCanvas, tempContext, blendMask };
|
||||
}
|
||||
|
||||
function makeLayers(size, count) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...makeLayerCanvas(size, index),
|
||||
index,
|
||||
x: size / 2 + ((index % 4) - 1.5) * size * 0.035,
|
||||
y: size / 2 + (Math.floor(index / 4) - 1.5) * size * 0.035,
|
||||
rotation: ((index * 7) % 24) * Math.PI / 180,
|
||||
opacity: 0.72 + (index % 3) * 0.08,
|
||||
}));
|
||||
}
|
||||
|
||||
function drawLayer(context, layer, variant) {
|
||||
const layerSize = layer.canvas.width;
|
||||
context.save();
|
||||
context.translate(layer.x, layer.y);
|
||||
context.rotate(layer.rotation);
|
||||
context.globalAlpha = layer.opacity;
|
||||
context.globalCompositeOperation = layer.index % 3 === 0 ? 'source-over' : 'multiply';
|
||||
|
||||
if (variant === 'blend') {
|
||||
layer.tempContext.clearRect(0, 0, layerSize, layerSize);
|
||||
layer.tempContext.globalCompositeOperation = 'source-over';
|
||||
layer.tempContext.drawImage(layer.canvas, 0, 0);
|
||||
layer.tempContext.globalCompositeOperation = 'destination-in';
|
||||
layer.tempContext.drawImage(layer.blendMask, 0, 0);
|
||||
context.drawImage(layer.tempCanvas, -layerSize / 2, -layerSize / 2);
|
||||
} else if (variant === 'crop') {
|
||||
const crop = layerSize * 0.1;
|
||||
context.drawImage(
|
||||
layer.canvas,
|
||||
crop,
|
||||
crop,
|
||||
layerSize - crop * 2,
|
||||
layerSize - crop * 2,
|
||||
-layerSize / 2,
|
||||
-layerSize / 2,
|
||||
layerSize,
|
||||
layerSize,
|
||||
);
|
||||
} else {
|
||||
context.drawImage(layer.canvas, -layerSize / 2, -layerSize / 2);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function renderScene(size, layers, variant) {
|
||||
outputCanvas.width = size;
|
||||
outputCanvas.height = size;
|
||||
const context = outputCanvas.getContext('2d');
|
||||
context.clearRect(0, 0, size, size);
|
||||
context.fillStyle = '#606060';
|
||||
context.fillRect(0, 0, size, size);
|
||||
|
||||
for (const layer of layers) {
|
||||
drawLayer(context, layer, variant);
|
||||
}
|
||||
|
||||
if (variant === 'mask') {
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = size;
|
||||
maskCanvas.height = size;
|
||||
const maskContext = maskCanvas.getContext('2d');
|
||||
const radial = maskContext.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
size * 0.2,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size * 0.7,
|
||||
);
|
||||
radial.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
radial.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
maskContext.fillStyle = radial;
|
||||
maskContext.fillRect(0, 0, size, size);
|
||||
context.globalCompositeOperation = 'destination-in';
|
||||
context.drawImage(maskCanvas, 0, 0);
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
}
|
||||
|
||||
function exportPng() {
|
||||
return new Promise(resolve => {
|
||||
outputCanvas.toBlob(blob => resolve(blob?.size ?? 0), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async function benchmarkDistanceTransform(size, iterations) {
|
||||
const binaryMask = new Uint8Array(size * size);
|
||||
const center = size / 2;
|
||||
const radius = size * 0.36;
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const dx = x - center;
|
||||
const dy = y - center;
|
||||
binaryMask[y * size + x] = dx * dx + dy * dy <= radius * radius ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const distance = calculateDistanceTransform(binaryMask, size, size);
|
||||
const output = new Uint8ClampedArray(size * size * 4);
|
||||
const distanceStats = await measure(
|
||||
() => calculateDistanceTransform(binaryMask, size, size),
|
||||
iterations,
|
||||
);
|
||||
const rasterStats = await measure(
|
||||
() => rasterizeDistanceFieldMask(distance, binaryMask, size * 0.1, output),
|
||||
iterations,
|
||||
);
|
||||
return { distanceTransform: distanceStats, rasterizeDistanceField: rasterStats };
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = `
|
||||
<thead><tr>
|
||||
<th>Size</th><th>Layers</th><th>Variant</th>
|
||||
<th>Render median (ms)</th><th>Render p95 (ms)</th>
|
||||
<th>PNG median (ms)</th><th>PNG bytes</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>`;
|
||||
const body = table.querySelector('tbody');
|
||||
for (const row of rows) {
|
||||
const element = document.createElement('tr');
|
||||
element.innerHTML = `
|
||||
<td>${row.size}</td>
|
||||
<td>${row.layers}</td>
|
||||
<td>${row.variant}</td>
|
||||
<td>${row.render.medianMs.toFixed(3)}</td>
|
||||
<td>${row.render.p95Ms.toFixed(3)}</td>
|
||||
<td>${row.export.medianMs.toFixed(3)}</td>
|
||||
<td>${row.pngBytes}</td>`;
|
||||
body.appendChild(element);
|
||||
}
|
||||
resultsElement.replaceChildren(table);
|
||||
}
|
||||
|
||||
async function runBenchmark() {
|
||||
const sizes = parseList(document.getElementById('sizes').value);
|
||||
const layerCounts = parseList(document.getElementById('layerCounts').value);
|
||||
const iterations = Math.max(1, Number.parseInt(document.getElementById('iterations').value, 10) || 1);
|
||||
const rows = [];
|
||||
const distanceTransforms = {};
|
||||
const total = sizes.length * layerCounts.length * variants.length;
|
||||
let completed = 0;
|
||||
|
||||
runButton.disabled = true;
|
||||
resultsElement.replaceChildren();
|
||||
jsonElement.textContent = '';
|
||||
|
||||
try {
|
||||
for (const size of sizes) {
|
||||
distanceTransforms[size] = await benchmarkDistanceTransform(size, iterations);
|
||||
for (const layerCount of layerCounts) {
|
||||
const layers = makeLayers(size, layerCount);
|
||||
for (const variant of variants) {
|
||||
statusElement.textContent = `Running ${completed + 1}/${total}: ${size}px, ${layerCount} layers, ${variant}`;
|
||||
renderScene(size, layers, variant);
|
||||
const render = await measure(() => renderScene(size, layers, variant), iterations);
|
||||
const exported = await measure(exportPng, iterations);
|
||||
rows.push({
|
||||
size,
|
||||
layers: layerCount,
|
||||
variant,
|
||||
render,
|
||||
export: exported,
|
||||
pngBytes: await exportPng(),
|
||||
});
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
benchmark: 'layerforge-browser-canvas-representative',
|
||||
runtime: {
|
||||
userAgent: navigator.userAgent,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
},
|
||||
config: { sizes, layerCounts, variants, iterations },
|
||||
distanceTransforms,
|
||||
rows,
|
||||
};
|
||||
renderTable(rows);
|
||||
jsonElement.textContent = JSON.stringify(report, null, 2);
|
||||
statusElement.textContent = `Complete: ${rows.length} scenarios.`;
|
||||
} catch (error) {
|
||||
statusElement.textContent = `Failed: ${error instanceof Error ? error.message : error}`;
|
||||
throw error;
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
runButton.addEventListener('click', () => {
|
||||
runBenchmark().catch(error => console.error(error));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import {
|
||||
applyLuminanceAsAlpha,
|
||||
calculateDistanceTransform,
|
||||
fillInverseAlphaMask,
|
||||
rasterizeDistanceFieldMask,
|
||||
} from '../js/mask/mask_pixel_utils.js';
|
||||
import { HistoryStack } from '../js/canvas/canvas_history.js';
|
||||
import { cloneLayers, getStateSignature } from '../js/utils/common_utils.js';
|
||||
|
||||
const DEFAULT_SIZES = [512, 1024, 2048];
|
||||
const DEFAULT_LAYER_COUNTS = [1, 4, 8, 16];
|
||||
const DEFAULT_ITERATIONS = 5;
|
||||
|
||||
function parseList(value, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
|
||||
const parsed = value
|
||||
.split(',')
|
||||
.map(item => Number.parseInt(item.trim(), 10))
|
||||
.filter(Number.isFinite);
|
||||
|
||||
if (parsed.length === 0 || parsed.some(item => item <= 0)) {
|
||||
throw new Error(`Expected a comma-separated list of positive integers, got: ${value}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOptions(argv) {
|
||||
const options = {
|
||||
format: 'table',
|
||||
sizes: DEFAULT_SIZES,
|
||||
layerCounts: DEFAULT_LAYER_COUNTS,
|
||||
iterations: DEFAULT_ITERATIONS,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--help' || argument === '-h') {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing value for ${argument}`);
|
||||
}
|
||||
|
||||
if (argument === '--format') {
|
||||
if (!['table', 'json'].includes(value)) {
|
||||
throw new Error(`Unsupported format: ${value}`);
|
||||
}
|
||||
options.format = value;
|
||||
} else if (argument === '--sizes') {
|
||||
options.sizes = parseList(value, DEFAULT_SIZES);
|
||||
} else if (argument === '--layer-counts') {
|
||||
options.layerCounts = parseList(value, DEFAULT_LAYER_COUNTS);
|
||||
} else if (argument === '--iterations') {
|
||||
options.iterations = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(options.iterations) || options.iterations < 1) {
|
||||
throw new Error(`Expected --iterations to be a positive integer, got: ${value}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown option: ${argument}`);
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`LayerForge offline frontend benchmark
|
||||
|
||||
Usage:
|
||||
npm run benchmark -- [options]
|
||||
|
||||
Options:
|
||||
--sizes 512,1024,2048 Pixel benchmark sizes
|
||||
--layer-counts 1,4,8,16 History/model benchmark sizes
|
||||
--iterations 5 Timed samples per operation
|
||||
--format table|json Output format (default: table)
|
||||
`);
|
||||
}
|
||||
|
||||
function percentile(sortedValues, percentileValue) {
|
||||
const index = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.max(0, Math.ceil(sortedValues.length * percentileValue) - 1),
|
||||
);
|
||||
return sortedValues[index];
|
||||
}
|
||||
|
||||
function measureSync(operation, iterations) {
|
||||
const warmupCount = Math.min(2, iterations);
|
||||
for (let index = 0; index < warmupCount; index += 1) {
|
||||
operation();
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const startedAt = performance.now();
|
||||
operation();
|
||||
samples.push(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
const sorted = samples.slice().sort((left, right) => left - right);
|
||||
return {
|
||||
medianMs: percentile(sorted, 0.5),
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
minMs: sorted[0],
|
||||
maxMs: sorted[sorted.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function createBinaryMask(size) {
|
||||
const mask = new Uint8Array(size * size);
|
||||
const center = size / 2;
|
||||
const radius = size * 0.36;
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const dx = x - center;
|
||||
const dy = y - center;
|
||||
mask[y * size + x] = dx * dx + dy * dy <= radius * radius ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
function createImageData(size) {
|
||||
const data = new Uint8ClampedArray(size * size * 4);
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
data[index] = index & 255;
|
||||
data[index + 1] = (index >> 3) & 255;
|
||||
data[index + 2] = (index >> 5) & 255;
|
||||
data[index + 3] = 255;
|
||||
}
|
||||
return { data };
|
||||
}
|
||||
|
||||
function benchmarkPixelOperations(size, iterations) {
|
||||
const pixelCount = size * size;
|
||||
const binaryMask = createBinaryMask(size);
|
||||
const distanceMap = calculateDistanceTransform(binaryMask, size, size);
|
||||
const rasterizedData = new Uint8ClampedArray(pixelCount * 4);
|
||||
const imageData = createImageData(size);
|
||||
const visibilityData = { data: new Uint8ClampedArray(pixelCount * 4) };
|
||||
const maskData = { data: new Uint8ClampedArray(pixelCount * 4).fill(255) };
|
||||
|
||||
return {
|
||||
distanceTransform: measureSync(
|
||||
() => calculateDistanceTransform(binaryMask, size, size),
|
||||
iterations,
|
||||
),
|
||||
rasterizeDistanceField: measureSync(
|
||||
() => rasterizeDistanceFieldMask(distanceMap, binaryMask, size * 0.1, rasterizedData),
|
||||
iterations,
|
||||
),
|
||||
luminanceToAlpha: measureSync(
|
||||
() => applyLuminanceAsAlpha(imageData),
|
||||
iterations,
|
||||
),
|
||||
inverseAlphaMask: measureSync(
|
||||
() => fillInverseAlphaMask(visibilityData, maskData),
|
||||
iterations,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createLayerModel(count, size) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `benchmark-layer-${index}`,
|
||||
x: (index % 4) * size * 0.04,
|
||||
y: Math.floor(index / 4) * size * 0.04,
|
||||
width: size * 0.72,
|
||||
height: size * 0.72,
|
||||
originalWidth: size,
|
||||
originalHeight: size,
|
||||
rotation: (index * 7) % 30,
|
||||
zIndex: index,
|
||||
blendMode: index % 3 === 0 ? 'normal' : 'multiply',
|
||||
opacity: 0.7 + ((index % 3) * 0.1),
|
||||
flipH: index % 5 === 0,
|
||||
flipV: index % 7 === 0,
|
||||
blendArea: index % 2 === 0 ? 25 : 0,
|
||||
cropBounds: index % 3 === 0
|
||||
? { x: size * 0.1, y: size * 0.1, width: size * 0.8, height: size * 0.8 }
|
||||
: undefined,
|
||||
imageId: `benchmark-image-${index}`,
|
||||
image: { src: 'data:image/png;base64,benchmark-placeholder' },
|
||||
}));
|
||||
}
|
||||
|
||||
function benchmarkHistory(count, size, iterations) {
|
||||
const layers = createLayerModel(count, size);
|
||||
const history = new HistoryStack({
|
||||
clone: cloneLayers,
|
||||
equals: (left, right) => getStateSignature(left) === getStateSignature(right),
|
||||
historyLimit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
cloneLayers: measureSync(() => cloneLayers(layers), iterations),
|
||||
stateSignature: measureSync(() => getStateSignature(layers), iterations),
|
||||
pushSnapshot: measureSync(() => {
|
||||
history.clear();
|
||||
history.push(layers);
|
||||
}, iterations),
|
||||
estimatedSnapshotBytes: new TextEncoder().encode(JSON.stringify(cloneLayers(layers))).byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
function roundStats(stats) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(stats).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'number' ? Number(value.toFixed(3)) : value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function printTable(report) {
|
||||
console.log('LayerForge offline frontend benchmark');
|
||||
console.log(`Node ${report.runtime.node} on ${report.runtime.platform} ${report.runtime.arch}`);
|
||||
console.log(`Iterations: ${report.config.iterations}`);
|
||||
console.log('');
|
||||
console.log('Pixel operations');
|
||||
console.log('size operation median p95');
|
||||
|
||||
for (const [size, operations] of Object.entries(report.pixelOperations)) {
|
||||
for (const [operation, stats] of Object.entries(operations)) {
|
||||
console.log(
|
||||
`${size.padStart(4)} ${operation.padEnd(25)} `
|
||||
+ `${stats.medianMs.toFixed(3).padStart(7)} ms `
|
||||
+ `${stats.p95Ms.toFixed(3).padStart(7)} ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Layer model/history operations');
|
||||
console.log('layers operation median p95');
|
||||
for (const [count, operations] of Object.entries(report.layerModel)) {
|
||||
for (const operation of ['cloneLayers', 'stateSignature', 'pushSnapshot']) {
|
||||
const stats = operations[operation];
|
||||
console.log(
|
||||
`${count.padStart(6)} ${operation.padEnd(25)} `
|
||||
+ `${stats.medianMs.toFixed(3).padStart(7)} ms `
|
||||
+ `${stats.p95Ms.toFixed(3).padStart(7)} ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Browser Canvas render/export: run benchmarks/browser.html in a browser.');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const report = {
|
||||
benchmark: 'layerforge-frontend-offline',
|
||||
runtime: {
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
config: {
|
||||
sizes: options.sizes,
|
||||
layerCounts: options.layerCounts,
|
||||
iterations: options.iterations,
|
||||
},
|
||||
pixelOperations: {},
|
||||
layerModel: {},
|
||||
skipped: [
|
||||
{
|
||||
benchmark: 'browser-canvas-render-export',
|
||||
reason: 'Use benchmarks/browser.html with a real browser Canvas implementation.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
for (const size of options.sizes) {
|
||||
report.pixelOperations[size] = Object.fromEntries(
|
||||
Object.entries(benchmarkPixelOperations(size, options.iterations))
|
||||
.map(([name, stats]) => [name, roundStats(stats)]),
|
||||
);
|
||||
}
|
||||
|
||||
const historySize = options.sizes[0];
|
||||
for (const count of options.layerCounts) {
|
||||
report.layerModel[count] = {
|
||||
...benchmarkHistory(count, historySize, options.iterations),
|
||||
};
|
||||
report.layerModel[count] = Object.fromEntries(
|
||||
Object.entries(report.layerModel[count]).map(([name, value]) => [
|
||||
name,
|
||||
typeof value === 'object' ? roundStats(value) : value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.format === 'json') {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(report);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.mjs",
|
||||
"test:coverage": "node --experimental-test-coverage --test tests/*.mjs",
|
||||
"benchmark": "node benchmarks/frontend.mjs",
|
||||
"benchmark:frontend": "node benchmarks/frontend.mjs",
|
||||
"lint": "eslint js/ tests/ --max-warnings=0",
|
||||
"lint:fix": "eslint js/ tests/ --fix",
|
||||
"test:coverage:critical": "node --experimental-test-coverage --test-coverage-include=js/canvas/canvas_history.js --test-coverage-include=js/persistence/contracts.js --test-coverage-include=js/utils/web_socket_manager.js --test-coverage-lines=65 --test-coverage-functions=65 --test-coverage-branches=50 --test tests/test_canvas_history.mjs tests/test_persistence_contracts.mjs tests/test_persistence_resilience.mjs tests/test_async_lifecycle_resilience.mjs",
|
||||
|
||||
Reference in New Issue
Block a user