e0a5f57793
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.
314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|