-
Notifications
You must be signed in to change notification settings - Fork 0
Performance DevelopmentPlan
Companion pages: Technical Index · Architecture Overview · CalculatorGen Architecture · CalculatorGen Roadmap · Resources & Bibliography
Note
Current status as of 2026-06-11. Stage 3A (Roslyn → ILGPU lowering) and Stage 3B (quaternion
axis GPU JIT) of the User Bulb sandbox have landed on feature/gpu-compute. The Tier-6 ILGPU
emitter described in CalculatorGen-Architecture.md § 0.5
is live; CPU AVX2 + perturbation paths remain the default for 2-D escape-time work, with GPU
dispatch enabled for User Bulb / User Equation when the active accelerator probes as CUDA/OpenCL.
Performance items below predate the GPU JIT. Tier-1 and Tier-2 items remain valid; Tier-3+ items should be re-evaluated against the GPU baseline before scheduling.
Useful mental model for prioritising the items below.
For an N-pixel render at iteration cap M, the work is
where M). Per-tier throughput:
| Tier | Effective iter/sec | Bottleneck |
|---|---|---|
Scalar (double) |
~1.0 × 10^9
|
ILP, cache pressure |
| AVX2 + FMA | ~3.5 × 10^9
|
Vector lane utilisation |
| AVX-512 (perturb) | ~5.0 × 10^9
|
δ-update memory bandwidth |
| ILGPU CUDA (1080-class) | ~4.0 × 10^{10}
|
Kernel launch + DeviceToHost copy |
| ILGPU CUDA (4090-class) | ~1.5 × 10^{11}
|
DeviceToHost copy → 8K-pitch host buffer |
Below M ≈ 500 the GPU loses to AVX2 because the kernel-launch + post-copy fixed costs dominate.
Above M ≈ 4 000 the GPU wins decisively, and the gap widens with zoom.
Baseline already optimized:
-
MandelbrotCalculator— SIMD DD (AVX2+FMA), AVX-512 perturbation lane, BLA, SA prelude, cardioid skip, block periodicity, devirtualizedIColorMapvia concrete-type generic dispatch, inline color, ref-orbit cache, CDF cache. -
FractalRenderHost— pooled BGRA scratch,Parallel.Forpost-FX,_d3dGateserialisation, stale-frame re-upload. -
DirectXRenderer— dynamic texture +WriteDiscardmap, full-screen triangle (no vbuf), opaque blend.
Location: Calculators/MandelbrotCalculator.cs:287-288
Current:
public void Calculate(CancellationToken ct = default)
{
var callingMethod = new StackTrace().GetFrame(1)?.GetMethod();
Debug.WriteLine($"Calculate() called from {callingMethod?.DeclaringType?.Name}.{callingMethod?.Name}{Environment.NewLine} ...");
...
}Problem: Debug.WriteLine is [Conditional("DEBUG")] so the arg-eval
disappears in Release — BUT new StackTrace().GetFrame(1)?.GetMethod() is
evaluated outside the [Conditional] call and still runs in Release.
Allocates StackTrace object + walks frames + reflects MethodBase. At 60
fps video that is 60 wasted allocs + walks per second plus GC pressure.
Fix: Wrap in #if DEBUG or extract into a [Conditional("DEBUG")]
static helper so the entire block compiles out in Release. 5-minute change.
Verify: Build Release, run video zoom 30 s. GC counters before/after
(GC.CollectionCount(0)).
Location: Rendering/DirectXRenderer.cs:442
Current:
_swapChain.Present(1, PresentFlags.None);Hard-locked to monitor refresh. Caps live preview to 60/120/240 Hz, caps video render to display refresh, and adds frame-pacing latency to single-image renders.
Fix:
- Add
public bool VSync { get; set; } = true;onIFractalRenderer/DirectXRenderer/DirectX12Renderer. - Replace the literal
1with(VSync ? 1u : 0u). - Wire
FractalRenderHostto setVSync = falsewhileIVideoZoomControlleris recording or while a single-image render is blocking on the calc-continuation Present. - Optional: enable
DXGI_PRESENT_ALLOW_TEARINGon the swap-chain creation path soPresent(0)actually tears (otherwise driver still waits on non-flip-discard chains). RequiresDXGI_SWAP_CHAIN_FLAG_ALLOW_TEARINGat chain creation.
Verify: Run video zoom, observe FPS counter unhitched from refresh rate. Live preview still vsync'd (no tearing in idle browsing).
Location: Rendering/DirectXRenderer.cs:362-391
Current: for (row = 0; row < height; row++) Buffer.MemoryCopy(...).
Per-row branch + per-row call overhead.
Fix: Detect mapped.RowPitch == width * 4. When equal, single
Buffer.MemoryCopy(srcPtr, dst, width*height*4, width*height*4). Fall back
to row loop only when GPU adds padding.
long rowBytes = (long)width * 4;
if (mapped.RowPitch == rowBytes)
{
Buffer.MemoryCopy(srcPtr, dst, rowBytes * height, rowBytes * height);
}
else
{
for (int row = 0; row < height; row++) { ... }
}Verify: 4K render upload time. Single copy will be ~10-30% faster than 2160 row-copies.
Location: Calculators/EscapeTimeCalculator.cs:134-138
Current:
private void DispatchByColorMap<TKernel>(TKernel kernel, CancellationToken ct)
where TKernel : struct, IFractalKernel
{
CalculateCore<TKernel, IColorMap>(kernel, ColorMap, ct);
}TMap is constrained to interface, so JIT cannot devirtualize
colorMap.Map(...) inside FillAuxAndColor. Every pixel pays a vtable
lookup. Hits Julia / BurningShip / Tricorn / Multibrot / Phoenix — half the
fractal catalogue.
Fix: Mirror MandelbrotCalculator.Calculate's concrete-type switch.
Build a DispatchByColorMapConcrete<TKernel> that switches on ColorMap's
runtime type and calls CalculateCore<TKernel, ConcretePalette>(...) so
the JIT specialises Map() per-palette and inlines it.
The list mirrors MandelbrotCalculator.cs:358-560 cases (skip the
3D/normal-aware themes that are Mandelbrot-only — fall back to the
interface-generic path for those).
Verify: Benchmark Julia render at maxIter=512, 1080p. Expect 1.5-2x speedup.
Location: Rendering/FractalRenderHost.cs:989-1023
Current: per-pixel unpack BGRA → 3 floats → scale → clamp → repack inside
Parallel.For. Scalar.
Fix: Process 4 pixels at a time with Vector128<byte>:
- Load 16 bytes (4 BGRA pixels) into
Vector128<byte>. - Widen to two
Vector128<short>(low / high halves). - Widen each to
Vector128<float>(4 lanes). - Apply
(v - 128) * contrast + 128 + brightness*255, clamp to [0,255]. - Narrow back to byte, repack.
Use Sse41.Pack*/Avx2.Permute* for the narrow path. Alpha lane masked
out + restored to 0xFF.
Verify: Adaptive-slider tick latency at 4K. Expect 4-8x on the post-FX pass.
Location: Rendering/FractalRenderHost.cs:1033-1038
Current: every UploadProcessedBuffer does
Array.Copy(dst, pre, n) → 8 MB at 1080p, 33 MB at 4K. Only
SaveLastFrameToPng consumes the snapshot.
Fix: Add _recordingActive flag set by the video controller's
recording start/stop. When true, skip the snapshot. The save path
(SaveLastFrameToPng) only runs from interactive UI and is gated by user
action — no race with video record.
if (!_recordingActive)
{
if (_uploadPrePool == null || _uploadPrePool.Length < n)
_uploadPrePool = new uint[n];
var pre = _uploadPrePool;
Array.Copy(dst, pre, n);
_lastPreOverlayBuffer = pre;
}
else
{
_lastPreOverlayBuffer = null;
}Verify: Video record at 1080p60. Per-frame upload time. Expect 5-10% frame-time cut.
Location: Calculators/EscapeTimeCalculator.cs:142-185
Current: scalar per-pixel loop. No SIMD, no cardioid skip, no periodicity.
Fix: Port the MandelbrotCalculator.ComputeRowSP SIMD lane structure
to a IFractalKernel.StepSimd(ref Vector<double> zr, ref Vector<double> zi, ...)
struct method. Each kernel struct implements it inline so the JIT
specialises.
Kernels in scope (escape-time z² + c-family, no Phoenix):
-
MandelbrotKernel— already trivially vectorizable -
JuliaKernel— same SIMD shape, c-constant broadcast -
BurningShipKernel— needsVector.Absfor|zr|,|zi| -
TricornKernel— needsVector.Negateonzi -
MultibrotKernel(z^n) — power = 3/4/5: unrolled SIMD; power = 2: same as Mandelbrot
Phoenix uses two-step memory; keep scalar path.
Verify: Julia/BurningShip frame time at 1080p. Expect 3-4x on SP path.
Location: Rendering/FractalRenderHost.cs:554-570
Current: every Trigger() does Task.Run(...).ContinueWith(...). New
Task + continuation + threadpool scheduling each frame.
Fix:
- One background
Threadstarted inFractalRenderHostctor. -
BlockingCollection<FrameJob>of capacity 1 (latest-only semantics). -
Trigger()clears + enqueues; the thread dequeues + runs Calculate inline. - Completion fires
FrameCompletedvia thread-pool callback (one ThreadPool dispatch per frame, instead of a Task + ContinueWith).
Verify: Sustained video at 60 fps. ETW threadpool counters before / after. Saves 0.1-0.5 ms per frame plus GC pressure on Task allocations.
Locations:
Calculators/MandelbrotCalculator.cs:693, 1082, 1250, 2508, 2590, 2644Calculators/EscapeTimeCalculator.cs:155, 200Rendering/FractalRenderHost.cs:999
Current: new ParallelOptions { CancellationToken = ct } every Calculate.
And Parallel.For(0, height, ...) partitions row-at-a-time which spawns
one work-item per row — height task creations per Calculate.
Fix:
- Field
private readonly ParallelOptions _po = new(). Set_po.CancellationToken = ctper Calculate. - Replace
Parallel.For(0, height, _po, body)withParallel.ForEach(Partitioner.Create(0, height, height / (Environment.ProcessorCount * 4)), _po, range => { for (int y = range.Item1; y < range.Item2; y++) body(y); }). Chunk count =procCount * 4gives good load balancing without per-row scheduling overhead.
Verify: Per-Calculate overhead on small frames (480p). Expect 5-15% improvement at low maxIter where Parallel scheduling dominates.
Move the SP escape-time inner loop to a D3D11 compute shader (HLSL CS 5.0)
or extend the existing ILGPU translator (UserBulbIlgpuTranslator) to the
escape-time family.
Scope:
- SP path only (zoom < ~1e15). DD/QD perturbation stays CPU — BLA tables + reference orbit don't port cleanly without rewrite.
- Compute shader writes directly to a
RWTexture2D<unorm float4>shared with the existing display SRV → zero CPU↔GPU round-trip per frame. - IColorMap → HLSL: code-gen palette evaluation per-theme (the
ColorGenpipeline already does compile-time C# codegen; extend to HLSL emit). Or for first cut: pass smooth + dist + normals back to CPU for color (loses round-trip but still wins on the iteration loop).
Expected gain: 10-30x on integrated GPU, 50-200x on discrete. Video runs at native refresh at maxIter 4096. Single-image render at 8K becomes sub-second on discrete.
Risks: Driver-specific perf cliffs at high maxIter (long-shader timeout on Windows TDR — work around with iteration-bucket dispatch). Float-only on most consumer GPUs (no FP64 SIMD lanes), so DD stays CPU.
Phases:
- HLSL CS for
MandelbrotKernelonly, palette = on CPU. - Generate HLSL palette evaluation from
ColorGen. End-to-end GPU. - Extend to
JuliaKernel,BurningShipKernel,TricornKernel,MultibrotKernel. - Move
ColorBufferto GPU-residentStructuredBuffer<uint>; eliminate the BGRA upload (renderer reads directly). - Investigate FP64 path via ILGPU/CUDA on discrete NVIDIA for DD on GPU.
Location: Calculators/MandelbrotCalculator.cs:213-216 (cache key)
Video zoom continuously recentres. Any centre delta busts the orbit cache → full reference-orbit recompute (single-thread, can be 100k+ iters at deep zoom).
Fix: Add an orbit-validity check before invalidating:
- Re-evaluate cached
_refZr,_refZiagainst the new centre deltadc. - If
|δ_n|stays within BLA validity radius at every checkpoint, reuse the orbit + rebuild only BLA / SA caches (cheaper than re-running the orbit). - Otherwise full rebuild.
Most video frames at deep zoom drift by sub-pixel amounts → orbit stays valid. Expected 30-70% reduction in HP video frame time at zoom > 1e25.
Location: color/iter buffer write paths in MandelbrotCalculator and
EscapeTimeCalculator.
ColorBuffer is consumed by GPU upload immediately after Calculate — no
CPU re-read. Avx.StoreAlignedNonTemporal bypasses cache pollution and
saves the cache-line eviction cost.
Requires the buffers to be 32-byte aligned. Allocate via GC.AllocateArray<uint>(n, pinned: true) or NativeMemory.AlignedAlloc. Pinned alloc also
avoids GC scan cost for the giant frame buffers.
Verify: L2/L3 cache miss counters via VTune / perf stat. Expect 5-15%
on 4K renders where buffers exceed L2.
Location: FracturingFogCLD.csproj and dependent calc projects.
Adds R2R native code alongside IL. Kills JIT-warmup tax on first frame after launch and on first frame after assembly reload (UserEquation / Sandbox hot-load).
Risk: Larger binary size (~30-50% per assembly).
Verify: Time-to-first-frame on cold launch.
- All Tier-1 changes are isolated to one file each. Land independently.
- Tier-2 has interdependencies: T2.3 (EscapeTime SIMD) lands cleanly only after T1.4 (concrete dispatch) — same file, same dispatch path.
- Tier-3 GPU path (T3.1) is the biggest single FPS win. Schedule its phase 1 immediately after Tier-1 + Tier-2 land, in parallel with T3.2 / T3.3.
- Add a benchmark harness in
Benchmarks/covering: Mandelbrot SP @ 1080p, Mandelbrot HP @ 1e20 zoom, Julia SP @ 1080p, BurningShip SP @ 1080p, Adaptive-slider repaint latency, Video frame time @ 1080p60. Run before and after each tier to keep gains visible.
- Tier 1 — T1.1 → T1.2 → T1.3 → T1.4. One PR each, verify each.
- Tier 2 — T2.1 → T2.2 → T2.3 (after T1.4) → T2.4 → T2.5.
- Tier 3 phase planning — design doc for T3.1 GPU compute. Begin T3.2 ref-orbit recycling and T3.3 non-temporal stores in parallel.
- Tier 3 build-out — T3.1 phases 1 → 5.
-
T2.4 —
FractalRenderHostnow owns a dedicated backgroundThread("FractalCalc") + aBlockingCollection<FrameJob>withboundedCapacity: 1.Trigger()drains any queued-but-unstarted job and enqueues the freshest one (latest-only semantics), so bursts of wheel / key-repeat triggers collapse before the calc thread sees them. The thread runs the stale-frame re-upload +Calculate(token)inline, then hands the post-calc upload (CDF build +UploadProcessedBuffer+FrameCompleted) off to the threadpool viaThreadPool.UnsafeQueueUserWorkItem+ cachedAction<UploadCtx>. Removes the per-frameTask.Run+ContinueWithpair (~4 allocs/frame) and removes the per-trigger threadpool dispatch hops.Dispose()callsCompleteAdding()+Join(2000)before tearing down the renderer. -
T2.5 — chunked
Partitioner.Create(0, h, chunk)replacesParallel.For(0, h, ...)everywhere inMandelbrotCalculator(7 sites),EscapeTimeCalculator(3 sites), andFractalRenderHost.UploadProcessedBuffer(1 site).chunk = max(1, h / (procCount * 4))so workers grab contiguous row blocks instead of single rows — collapses scheduling dispatch count fromhto~procCount * 4per Calculate. Three straynew ParallelOptions()sites inMandelbrotCalculator(Adaptive HE, band-dither recolor, plain recolor) now use the cached_pofield. Largest win on small frames + low maxIter where scheduling overhead dominated row body cost. -
Video iter-cap dialog picker (Phase 1) —
VideoIterCapMode { Off, Global, PerTile }enum added toAbstractions/Models/SlideshowConfig.cs, persisted onVideoSettingsConfig.IterCapMode(defaultGlobal= prior auto-adaptive behaviour). Wired throughVideoZoomRequest,VideoSettingsViewModel(IterCapModeslist +IterCapModestring), the embeddedVideoSettingsView(ComboBox row under "Adaptive iter cap (perf vs quality)"), andShellViewModel.StartVideoFromConfig.FractalRenderHost.Video.cshonours the mode in the adaptive ratchet (Offkeeps_videoIterCappinned at 1.0 so the calculator always runs at full maxIter — strong-HW path) and in the per-frame iter application.PerTileis a Phase-1 stub:StartVideo/StartSlideshowemit a one-timeConsole.Errorwarning and the runtime treats it asGlobal. Phase 2 (true per-tile cap) requires either multi-call Calculate on sub-rects or pushing a per-tile cap array into the iteration loop in every color-map specialisation — out of scope for the Phase 1 commit, tracked as a follow-up. -
Video iter-cap dialog picker (Phase 2) —
MandelbrotCalculatorgainsint[]? PerRowMaxIter; when non-null and sized toHeight, rowyusesPerRowMaxIter[y]instead of the globalMaxIterations. Only the SP path (CalculateDoublePrecision/ComputeRowSP) honours it; HP (DD/QD) perturbation paths still use the global cap (Phase 2.1 follow-up).FractalRenderHost.Video.csdivides the frame intoTileBands = 8vertical row bands. After each successful Calculate the SP path samplesIterationBufferalong a single near-mid-band row atsamplesPerBand = 32strides per band —O(TileBands * 32)per frame.BuildPerRowMaxIterCapsmoothsteps the normalised band avg dwell over[TileInteriorLo=0.50, TileInteriorHi=0.90]and lerps the per-band cap multiplier from1.0(full quality, boundary detail) down toTileMinCapMult=0.40(interior-dominated, capped). Caps floor at 64 iter. The per-row array is pooled + reused across frames; cleared at video end + slideshow end + run start so interactiveTrigger()reverts to the global cap path. PerTile no longer routes to Global — the Phase 1 fallback warning + one-shot guard field are removed. -
Video iter-cap dialog picker (Phase 2.1) — extends
PerRowMaxIterhonouring to the HP (DD/QD) perturbation path (CalculateHighPrecisiondispatchingComputeRowPT8/PT4/Scalar) and theCalculateOrbitAwarepath (orbit traps, stripe average, TIA, etc.). Deep-zoom Mandelbrot regions (Deep Julias visual class) use the HP path; without this, PerTile mode produced no win past the HP-promote threshold. Each row body readsPerRowMaxIter[y]once, falls back toMaxIterationswhen null or zero. Reference orbit length is pixel-cap independent so BLA/SA tables are not invalidated by per-row caps. Adds an in-set rewrite post-row: any pixel whoseIterationBufferentry hit the row cap (iters >= rowMaxIt < MaxIterations) is overwritten withMaxIterationsso the recolor'siters >= maxIterin-set gate classifies it correctly — semantically right for unescaped pixels and prevents visible iter-banding at band boundaries. -
Video iter-cap dialog picker (single-shot Video Zoom dialog) —
AvaloniaDialogs.ShowVideoAsync(single-shot Video Zoom dialog under the FloatingMenu) gains the same Adaptive iter-cap ComboBox the embedded VideoSettingsView already had. BothstartBtn.ClickandslideshowBtn.ClickpopulateIterCapModefrom the picker. Default Global preserves the prior behaviour. -
Video iter-cap dialog picker (alt-calc extension) —
EscapeTimeCalculator(used by Julia / BurningShip / Tricorn / Multibrot / Phoenix) gainsint[]? PerRowMaxIterwith the same semantics asMandelbrotCalculator. All three calc paths (CalculateCoreSimd,CalculateCore,CalculatePhoenix) honour the per-row cap and run the in-set rewrite post-row.SyncAltCalculatorForVideoFramecopies the per-row array onto anEscapeTimeCalculatorcast. Generated calcs underCalculators/Generated/are template-driven byCalculatorGenand don't honour per-row caps yet — listed as a follow-up requiring template + regen. -
Video iter-cap dialog picker (band-stat smoothing) —
SampleBandDwellStatsnow averagesBandRowsPerBand = 4evenly- spaced rows per band atsamplesPerBand = 32strides per row (1024 IterationBuffer reads per frame total) instead of a single near-midpoint row. Adds an EMA (emaAlpha = 0.40) so per-band stats refresh fully over ~5 frames — fast enough to track region changes during a zoom, slow enough that a single noisy frame doesn't whip the cap. First frame after StartVideo/StartSlideshow still records the raw average (no EMA prior).
-
T3.3 (light) — pinned LOH alloc via
GC.AllocateUninitializedArray<T>(n, pinned: true)on allMandelbrotCalculator+EscapeTimeCalculatoroutput buffers and theFractalRenderHostupload-pool buffers. Eliminates per-frameGCHandle.Alloc/Free(LOH pin is built into the allocation) and removes the buffers from the GC mark-and-compact scan. Non-temporalAvx.Store*writes deferred — needs the SIMD write paths refactored to use raw pointers, which is a larger touch. -
T3.4 —
<PublishReadyToRun>true</PublishReadyToRun>+<PublishReadyToRunComposite>true</PublishReadyToRunComposite>set on thePublish|x64+Publish|AnyCPUconfigurations ofFracturingFogCLD.csproj. No effect ondotnet buildDebug/Release; takes effect ondotnet publish -c Publishwhich now emits native AOT-precompiled code alongside IL for cold-start + first-frame perf.
Why deferred: correct mathematical recycling requires per-pixel dc
plumbing into three SIMD row paths (ComputeRowPT8, ComputeRowPT4,
ComputeRowPTScalar) so the perturbation loop sees dc' = (pixel - displayCenter) + (displayCenter - cachedCenter).
Each row path is ~150 lines of tightly-tuned AVX2/AVX-512 with its own SA
prelude + BLA skip; adding a (ΔcR, ΔcI) parameter and threading it through
the iteration loop without breaking the BLA/SA validity bounds needs a
dedicated session with side-by-side render verification.
Concrete steps for the follow-up branch:
- Add
_refRecycleOffsetX/Yfields. Populated whenComputeReferenceOrbit*detects centre drift belowrecycleTolerance = pixelScale * 0.5. - Pass
(ΔcR, ΔcI)intoComputeRowPT8/ComputeRowPT4/ComputeRowPTScalaras scalar arguments (broadcast inside the row). - Per-pixel
dcR = (x - halfW) * scale + ΔcR; dcY = (y - halfH) * scale + ΔcI. - Force
EnsureBlaTable+EnsureSeriesApproximationrebuild when Δ non-zero (BLA coefficients depend ondcMaxAbswhich grew by|Δ|). - Visual regression test: render a video zoom at zoom > 1e30, compare recycled vs full-rebuild frames. Expect identical output to within colour-LSB tolerance.
Expected gain on hit: skip the single-threaded reference-orbit loop (~100k+ iters of DD/QD math per frame at zoom > 1e25). 30-70% video-frame time reduction at deep zoom.
Rendering/MandelbrotGpuKernel.cs — D3D11 compute shader (HLSL CS 5.0)
for the SP Mandelbrot escape-time inner loop. Owns its own cs_5_0
blob compiled via Vortice.D3DCompiler, a 48-byte cbuffer for
per-frame params (split centre + split scale for ~6 extra mantissa
bits past plain FP32), and two StructuredBuffer<uint> / <float> UAVs
for the iter + smooth outputs. Run() is synchronous: Dispatch →
CopyResource → Map(Read) → memcpy into the caller's pinned
int[] + float[]. 8×8 threadgroup, one thread per pixel,
whole-cardioid + period-2 bulb early-out matches the CPU SIMD path.
Phase 1 host integration — landed.
-
DirectXRenderer.TryGetD3D11(out device, out context)exposes the device + immediate context the swap chain is bound to. Returns false on non-Windows / non-D3D11 backends (GL / Skia) — caller falls back to CPU. -
MandelbrotCalculator.UseGpuCompute+GpuKernelproperties.CalculateDoublePrecisionbranches toGpuKernel.Run(...)when the toggle is on + kernel present +PerRowMaxIternull. The CPU palette stage runs as aParallel.ForEachover rows withFillAuxAndColorSPper pixel (consuming the GPU's iter + smooth writeback). On kernel exception the call falls through to the CPU SIMD path with aDebug.WriteLine— user still gets a frame. -
IFractalRenderHost.UseGpuComputelazy-constructs the kernel on first true assignment; null when the renderer isn't D3D11. Cleaned up inDisposebefore tearing the renderer down. -
MandelbrotGpuKernelctor takes the host's_d3dGatelock;Run()wraps its entire dispatch + readback inlock (_d3dGate)so the kernel never overlaps the swap-chain Render. ID3D11DeviceContext (immediate) is not thread-safe. - UI: Ctrl+G toggles GPU compute.
MainViewModel.UseGpuComputedelegates to the host and re-reads after assignment so the property reflects "didn't engage" when the renderer isn't D3D11. Perf HUD's precision label appends "(GPU)" when the kernel actually ran this frame.
Phase 1.b landed:
-
PerRowMaxIter on GPU.
MandelbrotGpuKernelgains aStructuredBuffer<uint>SRV at t0 and agUsePerRowcbuffer flag. Shader loop bound becomesrowMaxIt = gUsePerRow ? gPerRow[y] : gMaxIter; row-capped unescaped pixels writegMaxIterto iter +0to smooth (same in-set rewrite as the CPU Phase 2.1 post-row pass).Run()takesint[]? perRowMaxIter;MandelbrotCalculatorpassesPerRowMaxIterdirectly — no more CPU fallback when both PerTile + GPU are active. -
z+dz UAVs. Kernel writes a packed
RWStructuredBuffer<float4>at u2 holding finalzr, zi, dr, diper pixel. Tracks derivative through the iteration loop with the standardd_{n+1} = 2 z_n d_n + 1recurrence (init(1, 0)matching the CPU SIMD convention). CPU writeback now drives distance-estimate via the standard|z|·log|z|/|dz|formula and the existingFillNormalhelper, so Phong / distance / orbit-glow themes work under GPU compute. -
Split GPU timing.
MandelbrotGpuKernel.LastDispatchMscovers cbuffer + per-row upload + Dispatch submission + implicit flush (the first Map blocks until the GPU finishes);LastReadbackMsisolates the three staging Map+memcpy passes.PerfStatsgainsRecordGpuDispatch/RecordGpuReadback+ a snapshot row. HUD shows a "gpu dis X rb Y ms" line whenGpuSampleCount > 0, hidden otherwise so the CPU-only case isn't cluttered.
Phase 1.b open items:
- Orbit-aware themes:
CalculateOrbitAwarekeeps CPU dispatch unconditionally — the kernel doesn't sample z per iteration. Per- orbit reductions inside the shader (stripe sum, TIA accumulator) are a phase 2 candidate. - GPU-resident colour. Phase 2 of the original plan: code-gen IColorMap → HLSL emit so palette runs on GPU and ColorBuffer stays GPU-side. Eliminates the per-frame readback.
MandelbrotGpuKernel gains a FractalKind { Mandelbrot, Julia, BurningShip, Tricorn } enum and three new cbuffer fields
(gFractalKind, gParam0, gParam1). Shader switches on gFractalKind
inside CSMain:
- Mandelbrot (0) — unchanged. Cardioid + period-2 bulb early-out only fires for this kind (other shapes have different in-sets).
-
Julia (1) —
z_0initialised to the pixel coord; iterationcis the constant(gParam0, gParam1). -
BurningShip (2) — pre-step transform
z := |Re(z)| + i|Im(z)|. -
Tricorn (3) — pre-step
z := conj(z).
EscapeTimeCalculator gains UseGpuCompute + GpuKernel (shared
instance, set by the host alongside the Mandelbrot one) and a
TryDispatchGpu helper. Called at the top of Calculate — when the
fractal type is shader-supported, dispatches the kernel, runs the
same CPU writeback as the Mandelbrot path (smooth + distance estimate
- normal from final z+dz), and skips the CPU switch entirely. Returns false for Multibrot (pow, deferred) and Phoenix (two-step memory, out of scope for the polynomial path).
The cbuffer grew from 64 to 64 bytes — the four new ints fit in the existing pad slots.
MandelbrotCalculator.MaxGpuZoom = 1e4 gates GPU dispatch off above the
band where the shader's split-centre + split-scale FP32 reconstruction
hits catastrophic cancellation. cx = cxHi + fx*scaleHi + cxLo + fx*scaleLo
needs an FP32 ULP smaller than the pixel-size; pixel-size at zoom 1e4
≈ 4.8e-7 already brushes the ULP of centres near 1, so 1e4 is the
conservative ceiling before users see pixelation + cardioid/bulb
mispredicates that paint the whole frame as in-set. CPU SP path
(double precision) handles cleanly through ~1e12 and HP (DD/QD) takes
over from there. HUD precision label drops the (GPU) suffix when the
gate engages so the user sees the path switch.
Deferred precision-uplift options (only land if profiling shows GPU matters at deep zoom on real user HW):
-
Full double-double in HLSL. Re-emit the centre reconstruction
- every iteration's z² + c step as DD arithmetic (Dekker TwoSum + Veltkamp split, two FP32 components per scalar). Lifts the ceiling toward the CPU's ~1e12 limit. Cost: roughly 5× shader work — every add becomes a 6-op TwoSum, every multiply becomes a 17-op DD mul. Likely loses GPU throughput against the calc-thread CPU SIMD path for the maxIter ranges modest HW runs, so the user-visible win is only on the small zoom band 1e4..1e8 where CPU SP still works but slower than DD-GPU. Implementation = full new HLSL file + DD helper inline; ~400 lines of careful shader code. Verify against the CPU path bit-for-bit before shipping.
-
GPU perturbation. Mirror the CPU HP path: one reference orbit computed CPU-side in DD/QD, uploaded as a
StructuredBuffer<float2>of (refZr, refZi) per iteration; shader iterates only the FP32 deltaδ_n = z_n − Z_nagainst the reference. Lifts the ceiling past 1e15 to wherever the reference orbit precision holds. Cost is high: needs the BLA table + series-approximation prelude ported too (or the CPU computes them and ships skip tables to GPU); also needs glitch detection per-pixel to spot pixels whose δ_n diverges from the reference and re-iterate them with a fresh nearby reference. ~800 lines of new HLSL + CPU-side orbit-upload pipeline. Real engineering month, not a session-scoped task.
Neither option is scheduled. Phase 2 (HLSL palette emit) and phase 4 (GPU-resident ColorBuffer) are the next worthwhile GPU lifts — they cut the per-frame readback cost regardless of zoom and benefit every GPU-eligible frame.
ColorGen now emits a HLSL twin of every theme's Map() body. Generated
themes implement IGpuHlslPalette (in Interefaces/IGpuHlslPalette.cs)
returning three strings:
-
HlslPaletteBody— the body of afloat3 EvalPalette(...)HLSL function with the 15 DSL inputs surfaced asfloatargs (canonical order inGpuPaletteInputOrder.FloatInputs). -
HlslPrelude— helper definitions (cg_mods,cg_hash,cg_fromHsv/Hsl, plus per-aritycg_paletteN). -
PaletteId— short SHA-256 fingerprint for kernel shader cache.
MandelbrotGpuKernel keeps two shader variants: _csBase (no colour
write — for non-IGpuHlslPalette themes) and _csByPaletteId[id]
(emits the GPU colour write to a new RWStructuredBuffer<uint> gColor : register(u3)). The kernel composes its shader source per-variant at
SetPalette time, splicing the prelude before EvalPalette and the
EvalPalette body inside a generated wrapper. Compile failures fall
back to _csBase so the CPU palette path still works.
Run gained an optional colorDst: uint[]? arg. When provided AND
HasGpuPalette is true, the kernel binds the colour UAV, dispatches
the colour-emitting CS, copies the colour staging buffer into the
caller's ColorBuffer via Buffer.MemoryCopy, and the calculator
skips the CPU palette pass entirely. When colorDst == null (legacy
themes or non-IGpuHlslPalette colour maps), behaviour is identical to
phase 1.b.
MandelbrotCalculator and EscapeTimeCalculator both detect
IGpuHlslPalette on the active ColorMap, call SetPalette, and
forward ColorBuffer as colorDst when GPU palette is live. Aux
buffers (distance / normal) stay zero on the GPU palette path — the
emitted DSL inputs in_dist, in_nx, in_ny get 0 inside the
shader (degrades gracefully for themes that read them; honest themes
that ship through the DSL pipeline either drive colour from smooth
-
t+arg+magor accept the degradation).
Limitations:
- Only ColorGen-emitted themes get the GPU palette. Hand-written
IColorMapimpls (HsvPalette, FirePalette, RainbowColorMap, etc.) stay on the CPU palette path. Adding GPU palette to a hand-written theme is a manual translation job — implementIGpuHlslPaletteon the type. - Orbit-aware themes (
IOrbitAwareColorMap) and interior-aware themes (IInteriorAwareColorMap) need per-step samples the kernel doesn't produce; they remain CPU-bound regardless. - HP/DD paths still go through CPU (zoom gate
<= 1e4on the GPU branch). HP zoom paints the CPU palette pass.
Expected gain: at 1080p with a ColorGen theme active, the per-frame
readback drops from 4 buffers (iter + smooth + finalZD) at ~3-5 MB
total to 1 buffer (color uint[]) at 8 MB but avoids the CPU palette
loop's ~5-15 ms per Mp pass. Net wins biggest on themes whose CPU
Map() does heavy work (palette interpolation, cg_palette,
multiple Hsv calls); marginal on trivial themes whose CPU eval is
already cache-resident.
Why deferred: new HLSL compute shader + new D3D11 dispatch path + new GPU↔CPU sync model. Phase 1 alone (Mandelbrot SP kernel, palette on CPU) needs roughly:
- ~200 lines of HLSL CS 5.0 (escape loop, smooth iter, derivative tracking for normal/distance estimate).
- New
D3D11ComputeRenderer(or extendDirectXRenderer) withCreateComputeShader, twoStructuredBuffer<float>outputs (smoothIter + distance),Dispatch((W+15)/16, (H+15)/16, 1)per frame. - CPU readback for palette eval (StagingResource + Map/Unmap), or move palette to HLSL too.
- Integration into
FractalRenderHost.Triggerselecting GPU vs CPU based on a quality / zoom gate (SP only — zoom < 1e15; DD/QD stays CPU). - Validation pass: pixel-by-pixel compare GPU vs CPU output across a regression set of regions to catch float precision drift.
Concrete steps for the follow-up branch (phase 1 only):
- New file
Rendering/MandelbrotComputeShader.hlslwith the[numthreads(16,16,1)]Mandelbrot SP escape loop emittingRWStructuredBuffer<float>smoothIter + dist. - New
Rendering/D3D11ComputeBackend.csowning the CS, theID3D11ComputeShader, and the dispatch + readback path. ReusesDirectXRenderer._device/_context. - Extend
IFractalRendererwithbool SupportsGpuCompute { get; }so the host can gate dispatch per backend. -
FractalRenderHost.Triggerdecision:ViewState.UseGpuCompute && zoom < 1e15 && fractalType == Mandelbrot && !IsHighPrecisionActive→ dispatch CS; else current CPU path. - Status string: surface
"GPU-CS"precision label so deep-zoom paths stay diagnosable. - Benchmark vs current CPU SIMD path on 1080p / 4K / 8K at maxIter 256 /
1024 / 4096. Document gain in
Benchmarks/.
Phases 2-5 (HLSL palette codegen, GPU-resident colour buffer, kernel extension to Julia/BurningShip/Tricorn/Multibrot, FP64 ILGPU/CUDA path) each get their own follow-up branches once phase 1 is verified.
Mixed results from BAB's hands-on testing at 1280x763. Quality and slideshow good across the board; specific regressions / pain points below.
Symptom: Sporadic delay between user-initiated region pick / manual
zoom and (a) the "Calculating..." status string appearing, and (b)
the render itself starting. Both lag, not just the status indicator.
Likely cause: Both lags point to dispatch-path latency, not just a display-thread issue. Candidates:
-
_d3dGatesemaphore held by prior frame's GPU upload → nextTriggerwaits before even setting status. -
_calcCts.Cancel()on the prior calc blocks until priorParallel.Forunrolls (cancellation is cooperative; row workers check token mid-loop). Heavy frames at deep zoom can sit 50-200 ms before cancel observed. - Status update + render start both fire from the same
Dispatcher.UIThread.Postcontinuation — UI thread queue backlog delays both together.
Next steps (separate branch):
- Audit
FractalRenderHost.Trigger/Hosting/AvaloniaShellBootstrapfor the order of (a) status-string assignment, (b) calc dispatch, (c)Dispatcher.UIThread.Post. - Move status flip to a synchronous UI-thread set before
Trigger. - Add a
_calcInFlightint +Interlocked.Increment/Decrementso the status binding showsCalculating...while count > 0, hides at 0.
Symptom: Multiple wheel clicks at the surface / shallow zoom feel choppy. User suspects renderer thrash from queued wheel events triggering back-to-back calcs.
Likely cause: Wheel handler calls Trigger() per click without
coalescing. Each click cancels the in-flight calc (CancellationToken from
_calcCts) and restarts. At shallow zoom the calc itself is fast (~5-20 ms),
so the wasted work isn't iters — it's Task.Run + ContinueWith + GPU
upload + present cycling 5-10x in a 200 ms window.
Next steps (separate branch):
- Add a wheel-event debouncer: accumulate
Δzoomover a sliding ~30-50 ms window, fire oneTrigger()on debounce expiry. - Or: keep per-click trigger but skip the GPU upload + present on cancelled calcs (currently the cancelled calc still falls through to the upload path because the cancel signal arrives after the row loop bails but before the present logic gates on it).
- T2.4 (dedicated calc thread + bounded queue cap 1, latest-only) would collapse the queued work naturally — currently moot per the earlier determination because the video loop is single-threaded, but the interactive path does benefit from queue coalescing under burst input. Reconsider T2.4 scope.
Symptom:
- Region "Blackhole Sun" → visually/empirically smoother video zoom; some choppy frames; overall good.
- Region "Feigenbaum Point" → very choppy video zoom; overall poor.
User hypothesis: Large inset count (minibrots + cardioids) drives the slowdown — Feigenbaum-point neighbourhood has dense small-feature structure at every zoom level, Blackhole-Sun-class regions are mostly inside-set (black) with sparser features.
Likely cause (matches hypothesis):
- Inside-set pixels short-circuit on cardioid-skip; minibrot interiors don't — period-doubling cascades around the Feigenbaum point produce dense families of minibrots whose interiors run the full maxIter loop.
- Block periodicity detection only kicks in mid-loop; small minibrots below the block threshold pay full iter cost.
- BLA/SA at deep zoom: small minibrots near the Feigenbaum point have tight BLA-validity radii, so the BLA-skip rate drops — more pixels fall back to per-iteration perturbation.
Next steps (separate branch / Tier 3 scope):
- Profile a 5 s capture of the Feigenbaum-point video zoom with PerfView /
dotnet-trace. Hot spots expected:
ComputeRowPT8inner iteration loop + colour map evaluation. - T3.1 GPU compute is the natural fix — moves the per-pixel inner loop off CPU entirely. Bumps the budget so dense-feature regions stay above 30 fps.
- Interim: investigate adaptive maxIter — drop maxIter dynamically during video record when frame budget runs hot, fade back up when budget recovers. Visual cost is mild iter-banding at the edges of minibrots during fast pan; acceptable for live preview, not for final encode.
- Investigate per-tile maxIter heuristic: track per-tile escape histogram from prior frame, cap maxIter at p99 of the previous frame's actual-iter histogram. Saves wasted iters on the inside-set lake.
-
A (status + render-start lag) —
FractalRenderHost.Triggerreordered.StatusRequestednow fires at the very top ofTrigger, beforeInvalidateAdaptiveCdf/ cancel /ApplyView/ alt-switch. The stale-frame re-upload moved off the UI thread into the sameTask.Runthat ownsCalculate(runs before the calc on the threadpool slot), so the calling thread doesn't block on a 5-15 ms GPU upload before the calc kicks off. Order vs the calc-completion upload is still guaranteed by_d3dGate. -
B (wheel / key-repeat coalesce) —
MainViewModel.OnInputViewChangedrate-limitsRenderHint.Fullhints. First Full in a burst fires immediately (instant feedback); subsequent Fulls within a 50 ms window arm a trailing_fullCoalesceTimerthat fires one finalTrigger()after the burst settles. Covers wheel-zoom AND keyboard W/S key-repeat at shallow zoom. Single-click feedback unaffected because the leading edge always fires. -
C (Feigenbaum-class chop interim) —
FractalRenderHost.Video.csadded_videoIterCapadaptive multiplier (rangeVideoIterCapMin=0.40 toVideoIterCapMax=1.00). EachRenderVideoFramemeasures elapsed and ratchets the cap byVideoIterCapDown=0.92 if over 1.5× a 33 ms budget, or byVideoIterCapUp=1.05 if under 0.9× budget.ApplyVideoFrameStateapplies the cap to the computed iter count (with a 64-iter floor) when iter is not user-locked. Ratchet is gentle so iter banding ramps smoothly across the frame sequence rather than snapping. Master switchVideoAdaptiveIterEnabled(public bool, default true); wire to UI later if banding becomes a complaint.
Symptom: When Adaptive Histogram Equalisation strength > 0, the adaptive-HE effect is applied to the colour theme once it is fully faded-in in a single jump — a jarring "just on" visual snap. User expects Adaptive HE to apply continuously during the crossfade, ramping with the fade.
Status: Documented + deferred. Not a perf bug; it's a visual-correctness bug in the slideshow crossfade pipeline. Captured here to avoid losing track; will be addressed in its own branch.
Likely cause (to verify): Adaptive-HE strength is read from the
incoming slide's settings and applied unconditionally at the end of the
crossfade transition, instead of being lerped from the outgoing slide's
strength to the incoming slide's strength across the crossfade t.
Next steps (separate branch):
- Locate the crossfade tick path in
UI.Avalonia/slideshow code. - Find where Adaptive-HE strength is fetched per frame.
- Lerp
heStrength = Lerp(prev.heStrength, next.heStrength, fadeT)forfadeT ∈ [0,1]across the crossfade window. - Verify visually: Adaptive-HE strength should ramp continuously across the crossfade with no "snap-on" at the end.