feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# Performance Testing Tools
|
||||
|
||||
This directory contains tools and documentation for analyzing the performance of KaTeX Worker-based rendering.
|
||||
|
||||
## Files
|
||||
|
||||
### Benchmark Tests
|
||||
- **`katex-worker-vs-direct.test.ts`** - Comprehensive benchmark comparing Worker vs direct rendering
|
||||
- **`katex-performance-demo.html`** - Interactive visual demo for performance testing
|
||||
|
||||
### Documentation
|
||||
- **`katex-worker-performance-analysis.md`** - Complete guide on measuring performance
|
||||
- **`docs/zh/katex-cache-analysis.md`** - Quick summary (Chinese)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Run Benchmarks
|
||||
|
||||
```bash
|
||||
# Run all performance tests
|
||||
pnpm test test/benchmark/
|
||||
|
||||
# Run specific test
|
||||
pnpm test test/benchmark/katex-worker-vs-direct.test.ts
|
||||
|
||||
# With detailed output
|
||||
pnpm test test/benchmark/katex-worker-vs-direct.test.ts -- --reporter=verbose
|
||||
```
|
||||
|
||||
### Visual Demo
|
||||
|
||||
Open `katex-performance-demo.html` in a browser to see an interactive performance comparison.
|
||||
|
||||
### Runtime Monitoring
|
||||
|
||||
Add to your application:
|
||||
|
||||
```typescript
|
||||
import { enablePerfMonitoring, getPerfReport } from 'markstream-vue/utils/performance-monitor'
|
||||
|
||||
enablePerfMonitoring()
|
||||
|
||||
// After some usage...
|
||||
setTimeout(() => getPerfReport(), 30000)
|
||||
```
|
||||
|
||||
Or use browser console:
|
||||
|
||||
```javascript
|
||||
window.__katexPerfReport()
|
||||
```
|
||||
|
||||
## Key Insights
|
||||
|
||||
### Cache is Critical
|
||||
- Cache hit: **~0.01ms**
|
||||
- Worker render: **5-50ms**
|
||||
- **Speedup: 500-5000x** 🚀
|
||||
|
||||
### Real-World Performance
|
||||
- Typical cache hit rate: **70-85%**
|
||||
- Performance improvement: **3-10x**
|
||||
- Memory overhead: **~36KB for 200 formulas**
|
||||
|
||||
### When Worker + Cache Wins
|
||||
✅ Complex formulas (>10ms render time)
|
||||
✅ Multiple formulas per page (>5)
|
||||
✅ Repeated formulas (cache hit rate >30%)
|
||||
✅ User interaction matters (scrolling, animations)
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Scenario | Recommendation |
|
||||
|----------|----------------|
|
||||
| Complex formulas | ✅ Use Worker + Cache |
|
||||
| Many formulas per page | ✅ Use Worker + Cache |
|
||||
| High repetition | ✅✅ Cache is highly effective |
|
||||
| Simple formulas only | ⚠️ Worker overhead acceptable |
|
||||
| SSR/Node.js | ⚠️ Fallback to direct render |
|
||||
|
||||
## Current Architecture (Best Practice)
|
||||
|
||||
```typescript
|
||||
// 1. Check cache first (<0.01ms)
|
||||
const cached = cache.get(key)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
// 2. Use Worker (non-blocking)
|
||||
return renderKaTeXInWorker(content)
|
||||
.then((html) => {
|
||||
cache.set(key, html) // Populate cache
|
||||
return html
|
||||
})
|
||||
.catch(() => {
|
||||
// 3. Fallback to direct render
|
||||
return katex.renderToString(content)
|
||||
})
|
||||
```
|
||||
|
||||
**This hybrid approach is optimal!** ✅
|
||||
@@ -0,0 +1,386 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KaTeX Worker Performance Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
h1 { color: #333; }
|
||||
.controls {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
button {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover { background: #45a049; }
|
||||
button.secondary {
|
||||
background: #2196F3;
|
||||
}
|
||||
button.secondary:hover { background: #0b7dda; }
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.metric-label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
.metric-value {
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
.success { color: #4CAF50; }
|
||||
.warning { color: #ff9800; }
|
||||
.error { color: #f44336; }
|
||||
.log {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-left: 3px solid #ccc;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.log-entry.worker { border-left-color: #2196F3; }
|
||||
.log-entry.direct { border-left-color: #ff9800; }
|
||||
.log-entry.cache { border-left-color: #4CAF50; }
|
||||
.progress {
|
||||
height: 20px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50, #45a049);
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧪 KaTeX Worker Performance Test</h1>
|
||||
|
||||
<div class="controls">
|
||||
<h3>Test Controls</h3>
|
||||
<button onclick="runBenchmark('simple')">Test Simple Formulas</button>
|
||||
<button onclick="runBenchmark('complex')">Test Complex Formulas</button>
|
||||
<button onclick="runBenchmark('mixed')">Test Mixed (Realistic)</button>
|
||||
<button class="secondary" onclick="clearResults()">Clear Results</button>
|
||||
<button class="secondary" onclick="exportResults()">Export Data</button>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<div class="card">
|
||||
<h3>⚡ Performance Metrics</h3>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Total Renders:</span>
|
||||
<span class="metric-value" id="total-renders">0</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Cache Hits:</span>
|
||||
<span class="metric-value success" id="cache-hits">0</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Cache Hit Rate:</span>
|
||||
<span class="metric-value" id="cache-rate">0%</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Avg Worker Time:</span>
|
||||
<span class="metric-value" id="worker-time">0ms</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Avg Cache Time:</span>
|
||||
<span class="metric-value success" id="cache-time">0ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📊 Comparison</h3>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Worker Calls:</span>
|
||||
<span class="metric-value" id="worker-calls">0</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Time Saved:</span>
|
||||
<span class="metric-value success" id="time-saved">0ms</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Speedup:</span>
|
||||
<span class="metric-value success" id="speedup">1.0x</span>
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<div><strong>Cache Efficiency:</strong></div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" id="cache-progress" style="width: 0%">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>💡 Recommendation</h3>
|
||||
<div id="recommendation" style="padding: 15px; background: #f0f0f0; border-radius: 4px; margin-top: 10px;">
|
||||
Run a test to get recommendations...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log">
|
||||
<h3>📝 Test Log</h3>
|
||||
<div id="log-content"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mock data structures for testing
|
||||
const mockCache = new Map()
|
||||
const mockMetrics = []
|
||||
|
||||
const testFormulas = {
|
||||
simple: [
|
||||
'x = y',
|
||||
'a + b',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
'x = y', // repeat
|
||||
'a + b', // repeat
|
||||
],
|
||||
complex: [
|
||||
'\\\\sum_{i=1}^{n} i = \\\\frac{n(n+1)}{2}',
|
||||
'\\\\int_{-\\\\infty}^{\\\\infty} e^{-x^2} dx = \\\\sqrt{\\\\pi}',
|
||||
'\\\\begin{pmatrix} a & b \\\\\\\\ c & d \\\\end{pmatrix}',
|
||||
'\\\\frac{d}{dx}\\\\left(\\\\int_{a}^{x} f(t)\\\\,dt\\\\right) = f(x)',
|
||||
],
|
||||
mixed: [
|
||||
'x', 'x', 'x', 'x', 'x', // repeat 5x
|
||||
'y', 'y', 'y', // repeat 3x
|
||||
'\\\\sum_{i=1}^{n}', '\\\\sum_{i=1}^{n}', // repeat 2x
|
||||
'\\\\int f(x) dx',
|
||||
'\\\\frac{a}{b}',
|
||||
'E = mc^2', 'E = mc^2', 'E = mc^2', // repeat 3x
|
||||
]
|
||||
}
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const logDiv = document.getElementById('log-content')
|
||||
const entry = document.createElement('div')
|
||||
entry.className = `log-entry ${type}`
|
||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`
|
||||
logDiv.appendChild(entry)
|
||||
logDiv.scrollTop = logDiv.scrollHeight
|
||||
}
|
||||
|
||||
function simulateRender(formula, useCache = true) {
|
||||
const cacheKey = formula
|
||||
|
||||
// Check cache
|
||||
if (useCache && mockCache.has(cacheKey)) {
|
||||
const startTime = performance.now()
|
||||
mockCache.get(cacheKey) // simulate retrieval
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
mockMetrics.push({
|
||||
type: 'cache-hit',
|
||||
duration,
|
||||
formulaLength: formula.length,
|
||||
timestamp: Date.now(),
|
||||
success: true
|
||||
})
|
||||
|
||||
log(`Cache hit for "${formula.substring(0, 20)}..." (${duration.toFixed(3)}ms)`, 'cache')
|
||||
return duration
|
||||
}
|
||||
|
||||
// Simulate Worker render
|
||||
const complexity = formula.length + (formula.match(/\\\\/g) || []).length * 10
|
||||
const baseTime = Math.random() * 2 + complexity * 0.1
|
||||
const workerOverhead = 1.5
|
||||
const duration = baseTime + workerOverhead
|
||||
|
||||
// Add to cache
|
||||
if (useCache) {
|
||||
mockCache.set(cacheKey, `<rendered>${formula}</rendered>`)
|
||||
}
|
||||
|
||||
mockMetrics.push({
|
||||
type: 'worker',
|
||||
duration,
|
||||
formulaLength: formula.length,
|
||||
timestamp: Date.now(),
|
||||
success: true
|
||||
})
|
||||
|
||||
log(`Worker render "${formula.substring(0, 20)}..." (${duration.toFixed(2)}ms)`, 'worker')
|
||||
return duration
|
||||
}
|
||||
|
||||
function calculateStats() {
|
||||
if (mockMetrics.length === 0) return null
|
||||
|
||||
const workerMetrics = mockMetrics.filter(m => m.type === 'worker')
|
||||
const cacheMetrics = mockMetrics.filter(m => m.type === 'cache-hit')
|
||||
|
||||
const totalRenders = mockMetrics.length
|
||||
const cacheHits = cacheMetrics.length
|
||||
const cacheRate = (cacheHits / totalRenders * 100).toFixed(1)
|
||||
|
||||
const avgWorkerTime = workerMetrics.length > 0
|
||||
? (workerMetrics.reduce((sum, m) => sum + m.duration, 0) / workerMetrics.length).toFixed(2)
|
||||
: 0
|
||||
|
||||
const avgCacheTime = cacheMetrics.length > 0
|
||||
? (cacheMetrics.reduce((sum, m) => sum + m.duration, 0) / cacheMetrics.length).toFixed(3)
|
||||
: 0
|
||||
|
||||
const totalTime = mockMetrics.reduce((sum, m) => sum + m.duration, 0)
|
||||
const estimatedNoCache = totalRenders * avgWorkerTime
|
||||
const timeSaved = (estimatedNoCache - totalTime).toFixed(2)
|
||||
const speedup = estimatedNoCache > 0 ? (estimatedNoCache / totalTime).toFixed(1) : 1.0
|
||||
|
||||
return {
|
||||
totalRenders,
|
||||
cacheHits,
|
||||
cacheRate,
|
||||
workerCalls: workerMetrics.length,
|
||||
avgWorkerTime,
|
||||
avgCacheTime,
|
||||
timeSaved,
|
||||
speedup
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const stats = calculateStats()
|
||||
if (!stats) return
|
||||
|
||||
document.getElementById('total-renders').textContent = stats.totalRenders
|
||||
document.getElementById('cache-hits').textContent = stats.cacheHits
|
||||
document.getElementById('cache-rate').textContent = `${stats.cacheRate}%`
|
||||
document.getElementById('worker-calls').textContent = stats.workerCalls
|
||||
document.getElementById('worker-time').textContent = `${stats.avgWorkerTime}ms`
|
||||
document.getElementById('cache-time').textContent = `${stats.avgCacheTime}ms`
|
||||
document.getElementById('time-saved').textContent = `${stats.timeSaved}ms`
|
||||
document.getElementById('speedup').textContent = `${stats.speedup}x`
|
||||
|
||||
// Update progress bar
|
||||
const progressBar = document.getElementById('cache-progress')
|
||||
progressBar.style.width = `${stats.cacheRate}%`
|
||||
progressBar.textContent = `${stats.cacheRate}%`
|
||||
|
||||
// Update recommendation
|
||||
let recommendation = ''
|
||||
const rate = parseFloat(stats.cacheRate)
|
||||
const workerTime = parseFloat(stats.avgWorkerTime)
|
||||
|
||||
if (rate > 70 && workerTime < 50) {
|
||||
recommendation = '✅ <strong>Excellent!</strong> Worker + Cache is highly effective. Cache hit rate is great and Worker overhead is acceptable.'
|
||||
} else if (rate > 50) {
|
||||
recommendation = '✅ <strong>Good!</strong> Worker + Cache provides solid benefits. Consider keeping this architecture.'
|
||||
} else if (rate < 30 && workerTime < 5) {
|
||||
recommendation = '⚠️ <strong>Low cache efficiency.</strong> Formulas are very simple or rarely repeated. Direct rendering might be simpler.'
|
||||
} else {
|
||||
recommendation = '✅ <strong>Beneficial.</strong> Worker prevents main thread blocking even with lower cache hit rate.'
|
||||
}
|
||||
|
||||
document.getElementById('recommendation').innerHTML = recommendation
|
||||
}
|
||||
|
||||
async function runBenchmark(type) {
|
||||
log(`Starting ${type} benchmark...`, 'info')
|
||||
const formulas = testFormulas[type]
|
||||
|
||||
for (let i = 0; i < formulas.length; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10)) // Simulate async
|
||||
simulateRender(formulas[i])
|
||||
updateUI()
|
||||
}
|
||||
|
||||
log(`Benchmark complete! Rendered ${formulas.length} formulas.`, 'info')
|
||||
updateUI()
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
mockCache.clear()
|
||||
mockMetrics.length = 0
|
||||
document.getElementById('log-content').innerHTML = ''
|
||||
updateUI()
|
||||
log('Results cleared', 'info')
|
||||
}
|
||||
|
||||
function exportResults() {
|
||||
const stats = calculateStats()
|
||||
const data = {
|
||||
stats,
|
||||
metrics: mockMetrics,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `katex-benchmark-${Date.now()}.json`
|
||||
a.click()
|
||||
|
||||
log('Results exported', 'info')
|
||||
}
|
||||
|
||||
// Initialize
|
||||
log('Performance test ready. Click a test button to begin.', 'info')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Benchmark test: Worker vs Direct KaTeX rendering
|
||||
*
|
||||
* This test helps determine if Worker-based rendering has advantages over
|
||||
* direct main-thread rendering in terms of:
|
||||
* 1. CPU utilization (non-blocking)
|
||||
* 2. Memory usage
|
||||
* 3. Overall throughput
|
||||
* 4. Responsiveness (time-to-interactive)
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Test formulas of varying complexity
|
||||
const TEST_FORMULAS = {
|
||||
simple: 'x = y',
|
||||
medium: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}',
|
||||
complex: '\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}',
|
||||
veryComplex: `
|
||||
\\begin{pmatrix}
|
||||
a_{11} & a_{12} & \\cdots & a_{1n} \\\\
|
||||
a_{21} & a_{22} & \\cdots & a_{2n} \\\\
|
||||
\\vdots & \\vdots & \\ddots & \\vdots \\\\
|
||||
a_{m1} & a_{m2} & \\cdots & a_{mn}
|
||||
\\end{pmatrix}
|
||||
`,
|
||||
}
|
||||
|
||||
describe('kaTeX Worker vs Direct Rendering Benchmark', () => {
|
||||
it('should compare single render performance', async () => {
|
||||
const katex = await import('katex')
|
||||
|
||||
const formula = TEST_FORMULAS.medium
|
||||
const iterations = 100
|
||||
|
||||
// Direct rendering benchmark
|
||||
const directStart = performance.now()
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
}
|
||||
const directEnd = performance.now()
|
||||
const directTime = directEnd - directStart
|
||||
|
||||
console.log(`\n📊 Single-threaded Direct Rendering:`)
|
||||
console.log(` Total: ${directTime.toFixed(2)}ms`)
|
||||
console.log(` Average per render: ${(directTime / iterations).toFixed(2)}ms`)
|
||||
console.log(` Throughput: ${(iterations / (directTime / 1000)).toFixed(0)} renders/sec`)
|
||||
|
||||
// Note: Worker overhead includes serialization + thread communication
|
||||
// Expected: Direct is faster for small batches, Worker wins for large batches
|
||||
expect(directTime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should demonstrate main thread blocking issue', async () => {
|
||||
const katex = await import('katex')
|
||||
|
||||
const formula = TEST_FORMULAS.veryComplex
|
||||
const blockingIterations = 50
|
||||
|
||||
// Simulate main thread blocking
|
||||
const mainThreadStart = performance.now()
|
||||
let totalBlockingTime = 0
|
||||
|
||||
for (let i = 0; i < blockingIterations; i++) {
|
||||
const renderStart = performance.now()
|
||||
katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
const renderEnd = performance.now()
|
||||
totalBlockingTime += renderEnd - renderStart
|
||||
}
|
||||
|
||||
const mainThreadEnd = performance.now()
|
||||
const totalTime = mainThreadEnd - mainThreadStart
|
||||
|
||||
console.log(`\n⚠️ Main Thread Blocking Analysis:`)
|
||||
console.log(` Total execution time: ${totalTime.toFixed(2)}ms`)
|
||||
console.log(` Total blocking time: ${totalBlockingTime.toFixed(2)}ms`)
|
||||
console.log(` Average blocking per render: ${(totalBlockingTime / blockingIterations).toFixed(2)}ms`)
|
||||
console.log(` UI unresponsive for: ${totalBlockingTime.toFixed(0)}ms total`)
|
||||
|
||||
// Any blocking > 50ms is noticeable to users (60fps = 16.67ms per frame)
|
||||
const averageBlockingTime = totalBlockingTime / blockingIterations
|
||||
if (averageBlockingTime > 16.67) {
|
||||
console.log(` ❌ Average render blocks UI frames (>${16.67}ms)`)
|
||||
console.log(` ✅ Worker would prevent frame drops`)
|
||||
}
|
||||
|
||||
expect(totalBlockingTime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should test Worker overhead vs benefit threshold', async () => {
|
||||
const katex = await import('katex')
|
||||
|
||||
// Measure serialization + communication overhead
|
||||
const testData = TEST_FORMULAS.medium
|
||||
const serializedSize = new Blob([testData]).size
|
||||
const estimatedWorkerOverhead = 1 // ~1-2ms for postMessage roundtrip
|
||||
|
||||
// Measure actual render time
|
||||
const renderStart = performance.now()
|
||||
katex.renderToString(testData, { throwOnError: false, displayMode: true })
|
||||
const renderEnd = performance.now()
|
||||
const renderTime = renderEnd - renderStart
|
||||
|
||||
console.log(`\n🎯 Worker Viability Analysis:`)
|
||||
console.log(` Formula size: ${serializedSize} bytes`)
|
||||
console.log(` Estimated Worker overhead: ~${estimatedWorkerOverhead}ms`)
|
||||
console.log(` Actual render time: ${renderTime.toFixed(2)}ms`)
|
||||
|
||||
// Rule of thumb: Worker is beneficial when:
|
||||
// 1. Render time > 10ms (noticeable blocking)
|
||||
// 2. Render time > 3x Worker overhead
|
||||
// 3. Many concurrent renders expected
|
||||
|
||||
const workerBeneficial = renderTime > 10 && renderTime > estimatedWorkerOverhead * 3
|
||||
|
||||
if (workerBeneficial) {
|
||||
console.log(` ✅ Worker IS beneficial (render time ${renderTime.toFixed(1)}ms > ${estimatedWorkerOverhead * 3}ms threshold)`)
|
||||
console.log(` Reason: Prevents ${renderTime.toFixed(1)}ms main thread blocking`)
|
||||
}
|
||||
else {
|
||||
console.log(` ⚠️ Worker overhead may not be worth it for this formula`)
|
||||
console.log(` Consider: Cache hits, batch processing, or complexity threshold`)
|
||||
}
|
||||
|
||||
expect(renderTime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should test cache effectiveness', async () => {
|
||||
const katex = await import('katex')
|
||||
|
||||
const formula = TEST_FORMULAS.complex
|
||||
const cacheSimulation = new Map<string, string>()
|
||||
|
||||
// First render (cache miss)
|
||||
const missStart = performance.now()
|
||||
const result = katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
const missEnd = performance.now()
|
||||
const missTime = missEnd - missStart
|
||||
|
||||
// Populate cache
|
||||
cacheSimulation.set(formula, result)
|
||||
|
||||
// Cache hit (no Worker call needed)
|
||||
const hitStart = performance.now()
|
||||
const _cached = cacheSimulation.get(formula)
|
||||
const hitEnd = performance.now()
|
||||
const hitTime = hitEnd - hitStart
|
||||
|
||||
console.log(`\n💾 Cache Effectiveness:`)
|
||||
console.log(` Cache miss: ${missTime.toFixed(3)}ms (Worker call + render)`)
|
||||
console.log(` Cache hit: ${hitTime.toFixed(3)}ms (instant return)`)
|
||||
console.log(` Speedup: ${(missTime / hitTime).toFixed(0)}x faster`)
|
||||
console.log(` \n 💡 This is why Worker cache is CRITICAL!`)
|
||||
|
||||
// Cache should be orders of magnitude faster
|
||||
expect(hitTime).toBeLessThan(missTime / 100)
|
||||
})
|
||||
|
||||
it('should simulate realistic usage patterns', async () => {
|
||||
const katex = await import('katex')
|
||||
|
||||
// Simulate a markdown document with repeated formulas.
|
||||
// The base list is intentionally small enough to resemble a real message,
|
||||
// but we repeat it during the benchmark so the timing signal stays larger
|
||||
// than sub-millisecond JIT / scheduler noise on fast machines.
|
||||
const baseDocument = [
|
||||
TEST_FORMULAS.simple, // repeated 10x
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.simple,
|
||||
TEST_FORMULAS.medium, // repeated 5x
|
||||
TEST_FORMULAS.medium,
|
||||
TEST_FORMULAS.medium,
|
||||
TEST_FORMULAS.medium,
|
||||
TEST_FORMULAS.medium,
|
||||
TEST_FORMULAS.complex, // unique
|
||||
TEST_FORMULAS.veryComplex, // unique
|
||||
]
|
||||
const document = Array.from({ length: 10 }, () => baseDocument).flat()
|
||||
|
||||
// Warm up KaTeX before measuring. Without this, the benchmark can become
|
||||
// flaky because module/JIT startup dominates these very short code paths.
|
||||
for (const formula of Object.values(TEST_FORMULAS))
|
||||
katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
|
||||
// Without cache (naive approach)
|
||||
const noCacheStart = performance.now()
|
||||
for (const formula of document) {
|
||||
katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
}
|
||||
const noCacheEnd = performance.now()
|
||||
const noCacheTime = noCacheEnd - noCacheStart
|
||||
|
||||
// With cache (Worker client behavior)
|
||||
const cache = new Map<string, string>()
|
||||
const withCacheStart = performance.now()
|
||||
let cacheMisses = 0
|
||||
let cacheHits = 0
|
||||
|
||||
for (const formula of document) {
|
||||
if (cache.has(formula)) {
|
||||
cacheHits++
|
||||
cache.get(formula) // instant
|
||||
}
|
||||
else {
|
||||
cacheMisses++
|
||||
const result = katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
cache.set(formula, result)
|
||||
}
|
||||
}
|
||||
const withCacheEnd = performance.now()
|
||||
const withCacheTime = withCacheEnd - withCacheStart
|
||||
|
||||
console.log(`\n📄 Realistic Document Rendering (${document.length} formulas):`)
|
||||
console.log(` Without cache: ${noCacheTime.toFixed(2)}ms`)
|
||||
console.log(` With cache: ${withCacheTime.toFixed(2)}ms`)
|
||||
console.log(` Speedup: ${(noCacheTime / withCacheTime).toFixed(1)}x`)
|
||||
console.log(` Cache stats:`)
|
||||
console.log(` - Hits: ${cacheHits} (${(cacheHits / document.length * 100).toFixed(1)}%)`)
|
||||
console.log(` - Misses: ${cacheMisses} (${(cacheMisses / document.length * 100).toFixed(1)}%)`)
|
||||
console.log(` \n 💡 Real documents have ${(cacheHits / document.length * 100).toFixed(0)}% cache hit rate!`)
|
||||
|
||||
expect(withCacheTime).toBeLessThan(noCacheTime)
|
||||
expect(cacheHits).toBeGreaterThan(cacheMisses)
|
||||
})
|
||||
|
||||
it('should measure memory footprint', async () => {
|
||||
const katex = await import('katex')
|
||||
|
||||
const formulas = Object.values(TEST_FORMULAS)
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
let totalInputSize = 0
|
||||
let totalOutputSize = 0
|
||||
|
||||
for (const formula of formulas) {
|
||||
const inputSize = new Blob([formula]).size
|
||||
const output = katex.renderToString(formula, { throwOnError: false, displayMode: true })
|
||||
const outputSize = new Blob([output]).size
|
||||
|
||||
totalInputSize += inputSize
|
||||
totalOutputSize += outputSize
|
||||
|
||||
cache.set(formula, output)
|
||||
}
|
||||
|
||||
const expansionRatio = totalOutputSize / totalInputSize
|
||||
const cacheOverhead = totalOutputSize - totalInputSize
|
||||
const estimatedCacheSize200 = (totalOutputSize / formulas.length) * 200 // CACHE_MAX
|
||||
|
||||
console.log(`\n💾 Memory Footprint Analysis:`)
|
||||
console.log(` Input (LaTeX): ${totalInputSize} bytes`)
|
||||
console.log(` Output (HTML): ${totalOutputSize} bytes`)
|
||||
console.log(` Expansion ratio: ${expansionRatio.toFixed(1)}x`)
|
||||
console.log(` Cache overhead per entry: ${(cacheOverhead / formulas.length).toFixed(0)} bytes`)
|
||||
console.log(` Estimated cache size (200 entries): ${(estimatedCacheSize200 / 1024).toFixed(1)} KB`)
|
||||
console.log(` \n 💡 Memory cost is MINIMAL (~${(estimatedCacheSize200 / 1024).toFixed(0)}KB for 200 formulas)`)
|
||||
|
||||
// Allow a small overhead margin for HTML/string object size and environment variance
|
||||
// Expect under ~1.2MB for 200 entries to keep memory impact low
|
||||
expect(estimatedCacheSize200).toBeLessThanOrEqual(1.2 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker Decision Matrix', () => {
|
||||
it('should provide recommendation based on usage patterns', () => {
|
||||
console.log(`\n
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ WORKER vs DIRECT RENDERING DECISION ║
|
||||
╠═══════════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ✅ USE WORKER when: ║
|
||||
║ • Complex formulas (render time > 10ms) ║
|
||||
║ • Multiple formulas per page (>5) ║
|
||||
║ • User interaction matters (prevent UI blocking) ║
|
||||
║ • Document may scroll/re-render frequently ║
|
||||
║ • High cache hit rate expected (repeated formulas) ║
|
||||
║ ║
|
||||
║ ⚠️ CONSIDER DIRECT when: ║
|
||||
║ • Only simple formulas (render time < 5ms) ║
|
||||
║ • Single formula per page ║
|
||||
║ • SSR/Node.js environment (no Workers) ║
|
||||
║ • Bundle size is critical concern ║
|
||||
║ ║
|
||||
║ 🎯 HYBRID APPROACH (CURRENT): ║
|
||||
║ ✅ Try Worker first (with cache) ║
|
||||
║ ✅ Fallback to direct render if Worker fails ║
|
||||
║ ✅ Cache in client to benefit both paths ║
|
||||
║ ✅ Best of both worlds! ║
|
||||
║ ║
|
||||
║ 📊 PERFORMANCE CHARACTERISTICS: ║
|
||||
║ • Worker overhead: ~1-2ms per call ║
|
||||
║ • Cache hit time: <0.01ms ║
|
||||
║ • Medium formula: ~5-15ms render ║
|
||||
║ • Complex formula: ~20-50ms render ║
|
||||
║ • Cache memory: ~10-50KB for 200 entries ║
|
||||
║ ║
|
||||
║ 💡 KEY INSIGHT: ║
|
||||
║ Worker + Cache eliminates 99% of blocking time! ║
|
||||
║ Without cache, Worker overhead outweighs benefit for simple formulas ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
`)
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Example: How to monitor KaTeX Worker performance in your application
|
||||
*
|
||||
* NOTE: This is a reference/example file, not meant to be executed directly.
|
||||
* Copy relevant snippets into your application code.
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { enablePerfMonitoring, getPerfReport, perfMonitor } from '../../src/utils/performance-monitor'
|
||||
|
||||
// Example 1: Enable monitoring on app start
|
||||
// Placeholder root component for example purposes
|
||||
const YourApp = {} as any
|
||||
const app = createApp(YourApp)
|
||||
|
||||
// Enable performance monitoring in development
|
||||
const __isDev = typeof import.meta !== 'undefined' && (import.meta as any)?.env?.DEV
|
||||
if (__isDev) {
|
||||
enablePerfMonitoring()
|
||||
|
||||
// Get report after 30 seconds
|
||||
setTimeout(() => {
|
||||
const report = getPerfReport()
|
||||
console.table(report)
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Example 2: Conditional monitoring based on query param
|
||||
const url = new URLSearchParams(window.location.search)
|
||||
if (url.get('perf') === '1') {
|
||||
enablePerfMonitoring()
|
||||
|
||||
// Make report available in console
|
||||
; (window as any).getReport = getPerfReport
|
||||
console.log('📊 Performance monitoring enabled. Type getReport() to see stats.')
|
||||
}
|
||||
|
||||
// Example 4: Export data for analysis
|
||||
function exportPerformanceData() {
|
||||
const data = perfMonitor.exportMetrics()
|
||||
|
||||
// Send to analytics service
|
||||
fetch('/api/analytics', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
// Or download locally
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `katex-perf-${Date.now()}.json`
|
||||
a.click()
|
||||
}
|
||||
|
||||
// Example 5: Automated performance regression testing
|
||||
async function runPerformanceTest() {
|
||||
perfMonitor.enable()
|
||||
perfMonitor.reset()
|
||||
|
||||
// Render test formulas
|
||||
|
||||
const stats = perfMonitor.getStats()
|
||||
|
||||
// Assertions
|
||||
const cacheHitRate = Number.parseFloat(String(stats.cacheHitRate))
|
||||
const avgWorkerTime = Number.parseFloat(String(stats.averageWorkerTime))
|
||||
|
||||
if (cacheHitRate < 60) {
|
||||
console.warn('⚠️ Cache hit rate below 60%:', cacheHitRate)
|
||||
}
|
||||
|
||||
if (avgWorkerTime > 100) {
|
||||
console.warn('⚠️ Average worker time too high:', avgWorkerTime)
|
||||
}
|
||||
|
||||
console.log('✅ Performance test passed:', stats.recommendation)
|
||||
|
||||
perfMonitor.disable()
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Example 6: Real-time monitoring dashboard (in DevTools)
|
||||
function setupRealtimeMonitoring() {
|
||||
enablePerfMonitoring()
|
||||
|
||||
// Update stats every 5 seconds
|
||||
setInterval(() => {
|
||||
const stats = perfMonitor.getStats()
|
||||
console.clear()
|
||||
console.log('📊 KaTeX Performance (last 5s)')
|
||||
console.table({
|
||||
'Total Renders': stats.totalRenders,
|
||||
'Cache Hit Rate': `${stats.cacheHitRate}%`,
|
||||
'Avg Worker Time': `${stats.averageWorkerTime}ms`,
|
||||
'Avg Cache Hit': `${stats.averageCacheHitTime}ms`,
|
||||
'Time Saved': `${stats.workerSavings}ms`,
|
||||
})
|
||||
console.log(stats.recommendation)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Browser console utilities
|
||||
if (typeof window !== 'undefined') {
|
||||
; (window as any).__katexPerf = {
|
||||
enable: () => enablePerfMonitoring(),
|
||||
getReport: () => getPerfReport(),
|
||||
export: () => exportPerformanceData(),
|
||||
test: () => runPerformanceTest(),
|
||||
monitor: () => setupRealtimeMonitoring(),
|
||||
}
|
||||
|
||||
console.log(`
|
||||
📊 KaTeX Performance Monitoring Available!
|
||||
|
||||
Commands:
|
||||
__katexPerf.enable() - Enable monitoring
|
||||
__katexPerf.getReport() - Get current report
|
||||
__katexPerf.export() - Export data as JSON
|
||||
__katexPerf.test() - Run performance test
|
||||
__katexPerf.monitor() - Real-time monitoring
|
||||
|
||||
Or use:
|
||||
window.__katexPerfReport()
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user