Parallel KidLisp Workers Feasibility Report #
Date: January 29, 2026
Status: Feasibility Analysis
Context: Can embedded layers like $cow render $39i and $r2f in parallel workers?
Executive Summary #
Verdict: Not Recommended - The complexity significantly outweighs the benefits.
The current architecture already runs all KidLisp execution in a dedicated worker (disk.mjs). Spawning child workers for embedded layers introduces substantial complexity (state synchronization, buffer management, worker lifecycle) for marginal performance gains. The recently implemented GPU compositing provides better ROI.
1. Current Architecture #
Execution Context #
┌─────────────────────────────────────────────────────────────────┐
│ Main Thread (bios.mjs) │
│ - Input handling, resize events │
│ - Window management, DOM │
└────────────────────────┬────────────────────────────────────────┘
│ postMessage
▼
┌─────────────────────────────────────────────────────────────────┐
│ Disk Worker (disk.mjs) │
│ - Runs piece code (paint(), beat(), act()) │
│ - Manages embedded layers │
│ - Calls graph.mjs for rendering │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Embedded Layer: $39i ││
│ │ - Evaluates KidLisp code each frame ││
│ │ - Renders to its own buffer ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Embedded Layer: $r2f ││
│ │ - Evaluates KidLisp code each frame ││
│ │ - Renders to its own buffer ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ SEQUENTIAL: embeddedLayers.forEach(layer => render(layer)) │
└─────────────────────────────────────────────────────────────────┘
Key Code Paths #
From kidlisp.mjs:5130-5175 - Sequential embedded layer rendering:
// Paint all embedded layers first
this.embeddedLayers.forEach((embeddedLayer) => {
this.renderSingleLayer(embeddedLayer, api, paintCount);
});
// Then composite them onto parent buffer
const layerBuffers = this.embeddedLayers.map((l) => ({
pixels: l.buffer.pixels,
width: l.buffer.width,
height: l.buffer.height,
alpha: l.alpha,
}));
api.compositeLayers(layerBuffers);
From kidlisp.mjs:14625-14750 - renderSingleLayer() implementation:
- Switches to embedded buffer
- Updates frame counters
- Evaluates KidLisp source code
- Handles beat scheduling
- Restores parent buffer
Why It's Sequential Now #
- Shared State - Embedded layers inherit from parent context (
parentEnv) - Buffer Management - Each layer has its own buffer, but compositing happens on shared array
- Determinism - Sequential execution ensures consistent frame results
- Simplicity - No synchronization overhead
2. Proposed Parallel Architecture #
Design Option A: Child Workers #
┌─────────────────────────────────────────────────────────────────┐
│ Disk Worker (Parent) │
│ - Spawns child workers for each embedded layer │
│ - Coordinates frame timing │
│ - Receives pixel buffers back │
│ - Composites final result │
└────────────────────────┬────────────────────────────────────────┘
│ spawn workers
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────────┐┌────────────────┐┌────────────────┐
│ Worker: $39i ││ Worker: $r2f ││ Worker: $other │
│ - KidLisp eval ││ - KidLisp eval ││ - KidLisp eval │
│ - Own buffer ││ - Own buffer ││ - Own buffer │
└────────────────┘└────────────────┘└────────────────┘
│ │ │
└───────────────┼───────────────┘
│ transferPixels
▼
GPU Composite Layer
Design Option B: SharedArrayBuffer Pool #
┌─────────────────────────────────────────────────────────────────┐
│ Shared Memory Pool (SharedArrayBuffer) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Buffer 0 │ │Buffer 1 │ │Buffer 2 │ │ ... │ │
│ │$39i │ │$r2f │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
Worker $39i Worker $r2f (parallel write)
3. Technical Barriers #
3.1 SharedArrayBuffer Not Available #
From shared-array-buffer-and-embedding.md:
| Header | Required | Current |
|---|---|---|
Cross-Origin-Embedder-Policy |
require-corp |
COMMENTED OUT |
Cross-Origin-Opener-Policy |
same-origin |
same-origin-allow-popups |
Status: window.crossOriginIsolated = false
Why it's disabled: COEP require-corp would break:
- NFT platform embedding (objkt, teia, OpenSea)
- External images without CORP headers
- YouTube embeds in chat
- External CDN assets
3.2 Without SharedArrayBuffer #
Must use Transferable ArrayBuffers instead:
// Send buffer TO worker (transfers ownership, original becomes detached)
childWorker.postMessage({
pixels: buffer.data.buffer
}, [buffer.data.buffer]);
// Worker sends back (also transfers)
self.postMessage({
pixels: resultBuffer
}, [resultBuffer]);
Problems:
- Double copy per frame - Send to worker, receive back
- No concurrent access - Only one owner at a time
- Race conditions - Must serialize access carefully
- Memory pressure - Multiple full-frame buffers in flight
3.3 State Synchronization Challenges #
Each KidLisp instance maintains state:
// Per-layer state that must be synchronized
{
frameCount: number,
paintCount: number,
beatCount: number,
lastBeatTime: number,
environment: Map, // Variables defined in KidLisp
canvas: { width, height },
audio: { bpm, triggered sounds },
random: { seed state }
}
Synchronization needed each frame:
frameCount,paintCountfrom parent- BPM and timing from global audio
- Random seed continuity
- Parent-defined variables via
parentEnv
3.4 Worker Lifecycle Complexity #
Current embedded layers are cheap to create/destroy:
// Current: Just add to array
this.embeddedLayers.push(new EmbeddedLayer(...));
// Proposed: Must spawn worker, wait for init, handle errors
const worker = new Worker(embeddedLayerWorkerURL);
await new Promise(resolve => {
worker.onmessage = (e) => {
if (e.data.type === 'ready') resolve();
};
});
New failure modes:
- Worker creation failure
- Worker crash mid-frame
- Message queue backup
- Memory leaks from abandoned workers
4. Performance Analysis #
Current Frame Budget #
At 60 FPS: 16.67ms per frame
Typical $cow frame breakdown:
| Phase | Time | Notes |
|---|---|---|
| Parent eval | ~2-4ms | $cow's own code |
| $39i eval | ~3-6ms | Simple piece |
| $r2f eval | ~3-6ms | Simple piece |
| GPU composite | ~0.5-1ms | ✅ Already optimized |
| Total | ~9-17ms | Usually fits budget |
Parallel Execution Ceiling #
Best case (perfect parallelism):
- Parent + max(child1, child2) instead of parent + child1 + child2
- Savings: ~3-6ms per frame
Realistic case (with overhead):
| Overhead | Time |
|---|---|
| Worker spawn | ~5-50ms (one-time) |
| postMessage per frame | ~0.5-2ms |
| Buffer transfer | ~0.5-1ms |
| Synchronization wait | ~1-3ms |
| Per-frame overhead | ~2-6ms |
Net gain: Potentially negative!
The overhead of worker communication can exceed the time saved by parallelization for simple embedded pieces.
When Parallelization Would Help #
| Scenario | Benefit |
|---|---|
| 4+ embedded layers | Moderate |
| Complex layers (>5ms each) | High |
| Long-running simulations | High |
| Simple layers (<3ms each) | Negative |
$cow (2 simple layers) |
Minimal |
5. Alternative Approaches (Already Implemented) #
✅ GPU Layer Compositing (Phase 2) #
From gpu-acceleration-plan.md:
// 8 layers composited in single GPU call
gpuCompositeLayers(layerBuffers); // ~0.5ms for any count
Status: Implemented in gpu-effects.mjs
✅ Batched Effect Pipeline (Phase 4) #
// zoom + scroll + contrast in one pass
gpuComposite({ zoom, scroll, contrast }); // ~1ms total
Status: Implemented in kidlisp.mjs
💡 Proposed: Async Layer Evaluation #
Instead of full worker parallelism, evaluate layers asynchronously within the same worker:
// Current (blocking)
embeddedLayers.forEach(layer => this.renderSingleLayer(layer));
// Proposed (cooperative scheduling)
for (const layer of embeddedLayers) {
this.renderSingleLayer(layer);
await scheduler.yield(); // Let other tasks run
}
Benefits:
- No new workers
- No buffer transfer
- Better responsiveness
- Simpler error handling
Drawback: Still sequential, just non-blocking
6. Recommendation #
For $cow Specifically: Don't Parallelize #
- Overhead > Savings - Worker communication costs exceed the ~6ms saved
- GPU already helps - Layer compositing is now GPU-accelerated
- Complexity budget - Better spent on other features
For Future High-Performance Pieces #
If a piece absolutely needs parallel embedded layers:
- Opt-in flag -
(embed $piece {:parallel true}) - SharedArrayBuffer subdomain -
isolated.kidlisp.comwith strict COOP/COEP - Worker pool - Pre-spawned workers, reused across frames
- Threshold - Only parallelize layers that take >5ms individually
Better Investment of Effort #
| Priority | Feature | Impact |
|---|---|---|
| 1 | ✅ GPU compositing | Done - big win |
| 2 | ✅ Batched effects | Done - reduces passes |
| 3 | WASM KidLisp interpreter | 10-50x eval speedup |
| 4 | Smarter layer caching | Skip unchanged layers |
| 5 | Parallel workers | Last resort |
7. Conclusion #
Parallel Web Workers for embedded KidLisp layers is technically possible but not recommended for the following reasons:
- SharedArrayBuffer unavailable due to embedding requirements
- Transferable overhead negates parallelism gains for simple layers
- State sync complexity adds maintenance burden
- GPU acceleration already delivered the easy performance wins
- WASM interpreter would provide better speedup for less complexity
The recently implemented GPU compositing (Phases 1-4) provides a better cost/benefit ratio than parallel workers.
Appendix: Quick Reference #
Check If Parallelization Would Help #
// In piece code, measure layer eval time
const t0 = performance.now();
embeddedLayers.forEach(l => renderSingleLayer(l));
const evalTime = performance.now() - t0;
// Worth parallelizing if:
// - embeddedLayers.length >= 3
// - evalTime > 8ms
// - individual layers take >3ms each
Enable SharedArrayBuffer (If Needed Later) #
In netlify.toml:
# Uncomment and accept breaking changes:
Cross-Origin-Embedder-Policy = "require-corp"
Cross-Origin-Opener-Policy = "same-origin"
Then verify:
console.log(window.crossOriginIsolated); // Should be true
console.log(typeof SharedArrayBuffer); // Should be 'function'