noVNC/tests/benchmark_buffer.html

737 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<title>Recording Buffer Benchmark</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 20px;
background: #1a1a2e;
color: #eee;
}
h1 { color: #00d4ff; }
.config {
background: #16213e;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.config label {
display: block;
margin-bottom: 10px;
}
.config input[type="number"] {
width: 100px;
padding: 8px;
border: none;
border-radius: 4px;
background: #0f3460;
color: #fff;
}
.methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.method {
background: #16213e;
padding: 20px;
border-radius: 8px;
border: 2px solid #0f3460;
}
.method h3 {
margin-top: 0;
color: #00d4ff;
}
.method p {
font-size: 0.9em;
color: #aaa;
margin-bottom: 15px;
}
button {
background: #e94560;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { background: #ff6b6b; }
button:disabled {
background: #555;
cursor: not-allowed;
}
.status {
margin-top: 10px;
padding: 10px;
background: #0f3460;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
min-height: 60px;
white-space: pre-wrap;
}
.progress {
height: 20px;
background: #0f3460;
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #e94560);
width: 0%;
transition: width 0.1s;
}
#results {
background: #16213e;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
#results h3 { color: #00d4ff; margin-top: 0; }
#resultsTable {
width: 100%;
border-collapse: collapse;
}
#resultsTable th, #resultsTable td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #0f3460;
}
#resultsTable th { color: #00d4ff; }
.success { color: #4ade80; }
.failure { color: #f87171; }
</style>
</head>
<body>
<h1>Recording Buffer Benchmark</h1>
<p>Test different storage methods for high-throughput VNC recording (~10MB/s)</p>
<div class="config">
<label>
Target data size (GB):
<input type="number" id="targetGB" value="0.5" min="0.1" max="10" step="0.1">
</label>
<label>
Simulated throughput (MB/s):
<input type="number" id="throughputMB" value="10" min="1" max="100" step="1">
</label>
<label>
Chunk size (KB):
<input type="number" id="chunkKB" value="64" min="1" max="1024" step="1">
</label>
</div>
<div class="methods">
<!-- Method 1: In-memory array -->
<div class="method">
<h3>1. In-Memory Array</h3>
<p>Current approach: Push Uint8Array chunks to a JavaScript array. Simple but memory-heavy.</p>
<button onclick="runBenchmark('memory')">Run Test</button>
<button onclick="stopBenchmark('memory')">Stop</button>
<button onclick="downloadData_('memory')" id="download-memory" disabled>Download</button>
<button onclick="clearData('memory')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-memory"></div></div>
<div class="status" id="status-memory">Ready</div>
</div>
<!-- Method 2: Pre-allocated ArrayBuffer -->
<div class="method">
<h3>2. Pre-allocated Buffer</h3>
<p>Allocate a large ArrayBuffer upfront and write into it. Avoids repeated allocations.</p>
<button onclick="runBenchmark('preallocated')">Run Test</button>
<button onclick="stopBenchmark('preallocated')">Stop</button>
<button onclick="downloadData_('preallocated')" id="download-preallocated" disabled>Download</button>
<button onclick="clearData('preallocated')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-preallocated"></div></div>
<div class="status" id="status-preallocated">Ready</div>
</div>
<!-- Method 3: Chunked arrays -->
<div class="method">
<h3>3. Chunked Arrays (64MB blocks)</h3>
<p>Store data in fixed-size 64MB ArrayBuffers. Balances memory efficiency with GC pressure.</p>
<button onclick="runBenchmark('chunked')">Run Test</button>
<button onclick="stopBenchmark('chunked')">Stop</button>
<button onclick="downloadData_('chunked')" id="download-chunked" disabled>Download</button>
<button onclick="clearData('chunked')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-chunked"></div></div>
<div class="status" id="status-chunked">Ready</div>
</div>
<!-- Method 4: File System Access API -->
<div class="method">
<h3>4. File Stream (OPFS)</h3>
<p>Stream to Origin Private File System. Keeps memory low but requires async writes.</p>
<button onclick="runBenchmark('opfs')">Run Test</button>
<button onclick="stopBenchmark('opfs')">Stop</button>
<button onclick="downloadData_('opfs')" id="download-opfs" disabled>Download</button>
<button onclick="clearData('opfs')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-opfs"></div></div>
<div class="status" id="status-opfs">Ready</div>
</div>
<!-- Method 5: IndexedDB -->
<div class="method">
<h3>5. IndexedDB Chunks</h3>
<p>Store chunks in IndexedDB. Persistent storage with automatic memory management.</p>
<button onclick="runBenchmark('indexeddb')">Run Test</button>
<button onclick="stopBenchmark('indexeddb')">Stop</button>
<button onclick="downloadData_('indexeddb')" id="download-indexeddb" disabled>Download</button>
<button onclick="clearData('indexeddb')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-indexeddb"></div></div>
<div class="status" id="status-indexeddb">Ready</div>
</div>
<!-- Method 6: Blob accumulation -->
<div class="method">
<h3>6. Blob Accumulation</h3>
<p>Periodically merge chunks into Blobs. Leverages browser's blob storage.</p>
<button onclick="runBenchmark('blob')">Run Test</button>
<button onclick="stopBenchmark('blob')">Stop</button>
<button onclick="downloadData_('blob')" id="download-blob" disabled>Download</button>
<button onclick="clearData('blob')">Clear</button>
<div class="progress"><div class="progress-bar" id="progress-blob"></div></div>
<div class="status" id="status-blob">Ready</div>
</div>
</div>
<div id="results">
<h3>Results</h3>
<table id="resultsTable">
<thead>
<tr>
<th>Method</th>
<th>Status</th>
<th>Data Written</th>
<th>Time</th>
<th>Throughput</th>
<th>Peak Memory</th>
</tr>
</thead>
<tbody id="resultsBody">
</tbody>
</table>
</div>
<script>
const benchmarks = {};
const results = [];
const downloadData = {}; // Store data for download after each test
function getConfig() {
return {
targetBytes: parseFloat(document.getElementById('targetGB').value) * 1024 * 1024 * 1024,
throughputBytesPerSec: parseFloat(document.getElementById('throughputMB').value) * 1024 * 1024,
chunkSize: parseFloat(document.getElementById('chunkKB').value) * 1024
};
}
function updateStatus(method, text) {
document.getElementById(`status-${method}`).textContent = text;
}
function updateProgress(method, percent) {
document.getElementById(`progress-${method}`).style.width = `${percent}%`;
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
return bytes + ' B';
}
function getMemoryUsage() {
if (performance.memory) {
return performance.memory.usedJSHeapSize;
}
return null;
}
function addResult(method, status, bytesWritten, timeMs, peakMemory) {
const throughput = timeMs > 0 ? (bytesWritten / (timeMs / 1000)) : 0;
const row = document.createElement('tr');
row.innerHTML = `
<td>${method}</td>
<td class="${status === 'Success' ? 'success' : 'failure'}">${status}</td>
<td>${formatBytes(bytesWritten)}</td>
<td>${(timeMs / 1000).toFixed(2)}s</td>
<td>${formatBytes(throughput)}/s</td>
<td>${peakMemory ? formatBytes(peakMemory) : 'N/A'}</td>
`;
document.getElementById('resultsBody').appendChild(row);
}
function stopBenchmark(method) {
if (benchmarks[method]) {
benchmarks[method].stopped = true;
}
}
async function clearData(method) {
const data = downloadData[method];
if (data) {
// Special cleanup for persistent storage
if (data.type === 'opfs') {
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry('benchmark.bin');
} catch (e) { /* ignore */ }
} else if (data.type === 'indexeddb') {
await new Promise((resolve) => {
const req = indexedDB.deleteDatabase(data.dbName);
req.onsuccess = resolve;
req.onerror = resolve;
});
}
}
delete downloadData[method];
disableDownload(method);
updateStatus(method, 'Data cleared');
updateProgress(method, 0);
}
function enableDownload(method) {
document.getElementById(`download-${method}`).disabled = false;
}
function disableDownload(method) {
document.getElementById(`download-${method}`).disabled = true;
}
async function downloadData_(method) {
const data = downloadData[method];
if (!data) {
alert('No data available for download');
return;
}
updateStatus(method, 'Preparing download...');
try {
let blob;
const startTime = performance.now();
switch (data.type) {
case 'array':
// Array of Uint8Arrays
updateStatus(method, `Creating blob from ${data.chunks.length} chunks...`);
blob = new Blob(data.chunks, { type: 'application/octet-stream' });
break;
case 'buffer':
// Single ArrayBuffer
updateStatus(method, `Creating blob from buffer (${formatBytes(data.buffer.byteLength)})...`);
blob = new Blob([data.buffer], { type: 'application/octet-stream' });
break;
case 'chunked':
// Array of ArrayBuffers (blocks)
updateStatus(method, `Creating blob from ${data.blocks.length} blocks...`);
blob = new Blob(data.blocks, { type: 'application/octet-stream' });
break;
case 'opfs':
// Read from OPFS
updateStatus(method, 'Reading from OPFS...');
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('benchmark.bin');
const file = await fileHandle.getFile();
blob = file;
break;
case 'indexeddb':
// Read from IndexedDB
updateStatus(method, 'Reading from IndexedDB...');
blob = await readIndexedDBAsBlob(data.dbName, data.storeName);
break;
case 'blob':
// Already a blob
blob = data.blob;
break;
default:
throw new Error('Unknown data type');
}
const prepTime = performance.now() - startTime;
updateStatus(method, `Download ready (prep took ${(prepTime/1000).toFixed(2)}s)\nSize: ${formatBytes(blob.size)}`);
// Trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `benchmark-${method}-${formatBytes(blob.size).replace(' ', '')}.bin`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a delay to ensure download starts
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (e) {
updateStatus(method, `Download error: ${e.message}`);
}
}
async function readIndexedDBAsBlob(dbName, storeName) {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, 1);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
});
const chunks = await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
db.close();
return new Blob(chunks, { type: 'application/octet-stream' });
}
async function runBenchmark(method) {
const config = getConfig();
const state = {
stopped: false,
bytesWritten: 0,
startTime: performance.now(),
peakMemory: getMemoryUsage() || 0
};
benchmarks[method] = state;
updateStatus(method, 'Starting...');
updateProgress(method, 0);
// Generate a chunk of random-ish data (simulating VNC frame data)
const chunk = new Uint8Array(config.chunkSize);
for (let i = 0; i < chunk.length; i++) {
chunk[i] = Math.floor(Math.random() * 256);
}
try {
switch (method) {
case 'memory':
await benchmarkMemory(state, config, chunk);
break;
case 'preallocated':
await benchmarkPreallocated(state, config, chunk);
break;
case 'chunked':
await benchmarkChunked(state, config, chunk);
break;
case 'opfs':
await benchmarkOPFS(state, config, chunk);
break;
case 'indexeddb':
await benchmarkIndexedDB(state, config, chunk);
break;
case 'blob':
await benchmarkBlob(state, config, chunk);
break;
}
const elapsed = performance.now() - state.startTime;
if (state.stopped) {
addResult(method, 'Stopped', state.bytesWritten, elapsed, state.peakMemory);
} else {
addResult(method, 'Success', state.bytesWritten, elapsed, state.peakMemory);
}
updateStatus(method, `Complete: ${formatBytes(state.bytesWritten)} in ${(elapsed/1000).toFixed(2)}s`);
} catch (e) {
const elapsed = performance.now() - state.startTime;
addResult(method, `Failed: ${e.message}`, state.bytesWritten, elapsed, state.peakMemory);
updateStatus(method, `Error: ${e.message}`);
}
updateProgress(method, 100);
}
// Method 1: Simple in-memory array
async function benchmarkMemory(state, config, chunk) {
disableDownload('memory');
const frames = [];
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
frames.push(chunk.slice()); // Copy the chunk
state.bytesWritten += chunk.length;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('memory', progress);
updateStatus('memory', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}\nFrames: ${frames.length}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
// Yield to browser
await new Promise(r => setTimeout(r, 0));
}
}
// Store for download
downloadData['memory'] = { type: 'array', chunks: frames };
enableDownload('memory');
}
// Method 2: Pre-allocated ArrayBuffer
async function benchmarkPreallocated(state, config, chunk) {
disableDownload('preallocated');
updateStatus('preallocated', 'Allocating buffer...');
// Try to allocate the full buffer
let buffer;
try {
buffer = new ArrayBuffer(config.targetBytes);
} catch (e) {
throw new Error(`Failed to allocate ${formatBytes(config.targetBytes)}: ${e.message}`);
}
const view = new Uint8Array(buffer);
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
let offset = 0;
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
const remaining = config.targetBytes - offset;
const writeSize = Math.min(chunk.length, remaining);
view.set(chunk.subarray(0, writeSize), offset);
offset += writeSize;
state.bytesWritten = offset;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('preallocated', progress);
updateStatus('preallocated', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
await new Promise(r => setTimeout(r, 0));
}
}
// Store for download (trim to actual bytes written if stopped early)
downloadData['preallocated'] = { type: 'buffer', buffer: buffer.slice(0, state.bytesWritten) };
enableDownload('preallocated');
}
// Method 3: Chunked arrays (64MB blocks)
async function benchmarkChunked(state, config, chunk) {
disableDownload('chunked');
const BLOCK_SIZE = 64 * 1024 * 1024; // 64MB blocks
const blocks = [];
let currentBlock = new Uint8Array(BLOCK_SIZE);
let blockOffset = 0;
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
const remaining = BLOCK_SIZE - blockOffset;
if (remaining >= chunk.length) {
currentBlock.set(chunk, blockOffset);
blockOffset += chunk.length;
} else {
// Fill current block and start new one
currentBlock.set(chunk.subarray(0, remaining), blockOffset);
blocks.push(currentBlock);
currentBlock = new Uint8Array(BLOCK_SIZE);
blockOffset = chunk.length - remaining;
currentBlock.set(chunk.subarray(remaining), 0);
}
state.bytesWritten += chunk.length;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('chunked', progress);
updateStatus('chunked', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}\nBlocks: ${blocks.length + 1}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
await new Promise(r => setTimeout(r, 0));
}
}
// Add final partial block (trimmed to actual size)
if (blockOffset > 0) {
blocks.push(currentBlock.slice(0, blockOffset));
}
// Store for download
downloadData['chunked'] = { type: 'chunked', blocks: blocks };
enableDownload('chunked');
}
// Method 4: Origin Private File System
async function benchmarkOPFS(state, config, chunk) {
disableDownload('opfs');
if (!navigator.storage || !navigator.storage.getDirectory) {
throw new Error('OPFS not supported in this browser');
}
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('benchmark.bin', { create: true });
const writable = await fileHandle.createWritable();
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
await writable.write(chunk);
state.bytesWritten += chunk.length;
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('opfs', progress);
updateStatus('opfs', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
}
}
await writable.close();
// Keep file for download (don't delete)
downloadData['opfs'] = { type: 'opfs' };
enableDownload('opfs');
}
// Method 5: IndexedDB
async function benchmarkIndexedDB(state, config, chunk) {
disableDownload('indexeddb');
const dbName = 'benchmark_db';
const storeName = 'chunks';
// Delete existing database
await new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = resolve;
req.onerror = resolve; // Ignore errors
});
// Create database
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open(dbName, 1);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = (e) => {
e.target.result.createObjectStore(storeName, { autoIncrement: true });
};
});
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
const BATCH_SIZE = 100; // Write in batches for better performance
let batch = [];
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
batch.push(chunk.slice());
state.bytesWritten += chunk.length;
if (batch.length >= BATCH_SIZE || i === chunksNeeded - 1) {
// Write batch to IndexedDB
await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
for (const item of batch) {
store.add(item);
}
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
batch = [];
}
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('indexeddb', progress);
updateStatus('indexeddb', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
}
}
db.close();
// Keep database for download (don't delete)
downloadData['indexeddb'] = { type: 'indexeddb', dbName, storeName };
enableDownload('indexeddb');
}
// Method 6: Blob accumulation
async function benchmarkBlob(state, config, chunk) {
disableDownload('blob');
const MERGE_THRESHOLD = 10 * 1024 * 1024; // Merge every 10MB
let pendingChunks = [];
let pendingSize = 0;
let blobs = [];
const chunksNeeded = Math.ceil(config.targetBytes / config.chunkSize);
const updateInterval = Math.max(1, Math.floor(chunksNeeded / 100));
for (let i = 0; i < chunksNeeded && !state.stopped; i++) {
pendingChunks.push(chunk.slice());
pendingSize += chunk.length;
state.bytesWritten += chunk.length;
// Periodically merge into a blob
if (pendingSize >= MERGE_THRESHOLD) {
const blob = new Blob(pendingChunks, { type: 'application/octet-stream' });
blobs.push(blob);
pendingChunks = [];
pendingSize = 0;
}
if (i % updateInterval === 0) {
const progress = (state.bytesWritten / config.targetBytes) * 100;
updateProgress('blob', progress);
updateStatus('blob', `Writing: ${formatBytes(state.bytesWritten)} / ${formatBytes(config.targetBytes)}\nBlobs: ${blobs.length}`);
const mem = getMemoryUsage();
if (mem) state.peakMemory = Math.max(state.peakMemory, mem);
await new Promise(r => setTimeout(r, 0));
}
}
// Final merge
if (pendingChunks.length > 0) {
blobs.push(new Blob(pendingChunks, { type: 'application/octet-stream' }));
}
// Create final blob (for download capability)
const finalBlob = new Blob(blobs, { type: 'application/octet-stream' });
updateStatus('blob', `Complete: ${formatBytes(state.bytesWritten)}\nFinal blob: ${formatBytes(finalBlob.size)}`);
// Store for download
downloadData['blob'] = { type: 'blob', blob: finalBlob };
enableDownload('blob');
}
</script>
</body>
</html>