Skip to content

Reduce GPU text memory, page-switch and resize latency#34

Merged
wieslawsoltes merged 12 commits into
mainfrom
agent/compact-gpu-text-atlases
Jul 22, 2026
Merged

Reduce GPU text memory, page-switch and resize latency#34
wieslawsoltes merged 12 commits into
mainfrom
agent/compact-gpu-text-atlases

Conversation

@wieslawsoltes

@wieslawsoltes wieslawsoltes commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

  • replace RGBA glyph/path coverage atlases with compact R8Unorm textures while retaining a separate RGBA color-glyph atlas
  • grow coverage, color, and path atlases geometrically on demand and preserve resident texels entirely on the GPU
  • batch newly visible glyph outlines, uniforms, compute work, and submission in bounded chunks so page switches do not issue per-glyph uploads
  • remove eager whole-font OpenType/CFF object graphs; shape from typed raw tables and decode requested CFF 1 Type 2 glyphs on demand
  • eliminate redundant rich-text render shaping, pool realized positioned characters within a 4,096-object bound, and release the pool when a document is replaced
  • accelerate deterministic path-atlas recovery by linearly retaining unaffected free regions and pruning only newly split regions
  • retain rich-text commands across selection-only repaints with a lazily allocated overlay scratch cache
  • remove steady visual-state, property-inheritance, rich-layout enumerator, and LOL/s reparenting allocations
  • reuse path-recovery ordering and trial storage without changing deterministic placement or exact fallback
  • add a reproducible EventPipe/GC/process/GPU profiler that runs every sample page in a fresh Release desktop process
  • preserve analytic 8x8 coverage, DPI/subpixel behavior, half-open winding, color glyphs, fallback/variation identity, and same-frame atlas recovery

Impact

The original sample configuration reserved 42,991,616 bytes in two RGBA coverage atlases. Exact known glyph/path atlas and staging startup residency is now 1,165,312 bytes: 97.3% lower, with the same maximum capacity and rendering quality.

The complete 47-page fresh-process Release profile produced these averages:

Metric Before After Change
Retained managed memory 80.51 MiB/page 30.29 MiB/page -62.4%
Managed allocation 154.10 KiB/frame 12.04 KiB/frame -92.2%
Physical footprint 403.90 MiB/page 369.69 MiB/page -8.5%
Compositor compilation 1.223 ms 0.250 ms -79.6%
Compile frames over 16.67 ms 428 0 -100%

The largest managed-memory improvements were:

  • Text Shaping Lab: 513.91 -> 70.07 MiB (-86.4%), allocation 22.8 -> 0.9 KiB/frame
  • Typography & Scripts: 345.18 -> 82.16 MiB (-76.2%), allocation 10.2 -> 0.9 KiB/frame
  • Text & Documents: 472.28 -> 189.27 MiB (-59.9%), allocation 4,128.4 -> 189.3 KiB/frame, compile 41.728 -> 2.037 ms
  • Font Glyph Browser: 60.85 -> 31.26 MiB (-48.6%), allocation 104.9 -> 37.4 KiB/frame, compile 2.491 -> 0.732 ms
  • LOL/s Benchmark: 65.83 -> 36.80 MiB (-44.1%), allocation 1,994.6 -> 28.9 KiB/frame, compile 2.999 -> 1.881 ms

The complete methodology, attribution, managed/process/GPU counters, caveats, and all 47 before/after rows are in docs/SAMPLE_PAGE_MEMORY_PROFILE.md. The atlas design and earlier matched browser-AOT results are in docs/GPU_TEXT_COVERAGE_CACHE_ARCHITECTURE.md.

Page switching and GPU uploads

Newly visible glyphs remain demand-driven; hidden pages are not pre-rasterized. First-use work is coalesced into contiguous uniform/outline writes, one dynamic-offset bind group, one compute pass, and one submission per bounded batch. A 13-glyph regression drops from 39 queue writes, 13 bind groups, and 13 compute passes to 3 writes, 1 bind group, 1 pass, and 1 submission. Atlas contents persist across navigation, avoiding renewed upload stalls without paying the memory cost of warming every hidden page.

Root causes and implementation

GC dumps for the largest pages showed whole-font OpenFontSharp CFF/layout graphs retaining glyphs and decoded arrays even when only a small glyph set was requested. The new clean-room CFF 1 reader retains compact INDEX/DICT offsets and evaluates only the requested Type 2 charstring, including subroutines and CID FDSelect/FDArray data. Deprecated seac and unsupported malformed programs keep a lazy compatibility fallback.

Text & Documents allocation stacks showed a second TextLayout used only to recompute decoration widths plus per-realization positioned-character objects. Rendering now uses retained shaped advances and presenter-local bounded recycling. CPU samples also showed path-atlas free-rectangle pruning dominating recovery; the deterministic algorithm now compares only new splits against the already-pruned set while preserving the same worst-case contract and exact recovery behavior.

The final allocation pass preserves one retained rich-text command list and lazily creates selection scratch storage only when selection changes. Visual-state trigger evaluation, inheritance propagation, and rich-layout traversal use cached/indexed paths without closure or interface-enumerator churn. LOL/s rotates its 500 retained controls in place, keeping one run and mutable brush per control. Across all 47 pages this pass reduced allocation another 72.2%, compilation 31.0%, and summed GC pause time 89.5% relative to the previous optimized checkpoint.

Commit 9ccfad7 separately fixes the retained-command regression discussed in review: compositor cleanup no longer clears a visual-owned text command context after successful replay. The control remains the sole owner, so the memory reduction is retained and second-frame text no longer disappears.

Clean-room research

The implementation was written independently from specifications, public contracts, and observable architecture; no external source was copied, ported, translated, or structurally reproduced.

Validation

  • dotnet test src/ProGPU.Tests/ProGPU.Tests.csproj -c Release --no-restore: 2,277 passed, 0 failed
  • dotnet test src/ProGPU.Tests.Headless/ProGPU.Tests.Headless.csproj -c Release --no-restore: 196 passed, 0 failed
  • CFF differential geometry tests: representative Latin, kanji, hiragana, and katakana match the established fallback within 0.001 font units and do not instantiate it on the demand path
  • all 47 sample pages completed the fresh-process 180-warmup/600-measurement Release desktop profile
  • current Release desktop Text & Documents run: 86.8 traced wall FPS and 0 compile frames over budget
  • Release browser publish: AOT-compiled 69 assemblies; WebGPU 3D and rich-text pages rendered with zero console errors or warnings
  • text-heavy headless screenshots inspected with unchanged coverage and quality
  • git diff --check: clean
  • branch history and final implementation audited for clean-room compliance; no copied/ported code or accidental vendoring found

Window resize follow-up

Interactive repaint is preserved. Cocoa can hold the normal Silk loop during live resize, so every framebuffer callback renders the latest physical size synchronously. ProGPU now suppresses the immediately following scheduled render for that same size, preventing duplicate layout/submission/present while keeping content live under the pointer. Surface capability selection is cached for the native surface lifetime and refreshed after a lost/outdated surface.

Multi-column documents retain width-independent block shaping, recycle positioned characters, use index ranges instead of copying rich-character lines, and skip duplicate measure/arrange layout at the same size. Width-dependent line breaking, embedded-control measurement, HarfBuzz line-boundary reshaping, DPI/subpixel snapping, raster quality, and atlas generation contracts remain unchanged.

The corrected matched Release workload rendered 299/299 framebuffer callbacks and suppressed 299/299 redundant scheduled frames, sustaining 116.15 visible FPS. Managed allocation improved from 1,761,326 B to 422,345 B/frame (-76.0%), Gen0 collections from 58 to 15, GC pause from 36.53 to 10.72 ms, live-resize callback max from 52.83 to 41.68 ms, and physical footprint from 496.19 to 463.46 MB. The browser Release AOT gallery also passed repeated 800x600, 1280x800, and 900x650 viewport changes with zero console errors.

The research matrix, platform-dispatch rationale, rejected alternatives, methodology, and reproduction command are in docs/WINDOW_RESIZE_PERFORMANCE.md.

Additional validation after the resize changes:

  • core Release tests: 2,277 passed, 0 failed
  • headless GPU Release tests: 196 passed, 0 failed
  • corrected live-resize focused tests: 157 passed, 0 failed
  • Release browser publish: AOT compiled and interactive resize smoke-tested
  • EventPipe CPU/allocation and Metal System Trace captured before and after
  • git diff --check: clean

@wieslawsoltes wieslawsoltes changed the title Reduce GPU text atlas residency with compact coverage buffers Demand-grow GPU text atlases and trim managed caches Jul 22, 2026
@wieslawsoltes wieslawsoltes changed the title Demand-grow GPU text atlases and trim managed caches Reduce GPU text memory and batch first-use uploads Jul 22, 2026
@wieslawsoltes
wieslawsoltes marked this pull request as ready for review July 22, 2026 07:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d5720df36

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ProGPU.Scene/Compositor.cs
@wieslawsoltes wieslawsoltes changed the title Reduce GPU text memory and batch first-use uploads Reduce GPU text memory, batch uploads, and bound fallback caches Jul 22, 2026
@wieslawsoltes wieslawsoltes changed the title Reduce GPU text memory, batch uploads, and bound fallback caches Reduce GPU text memory, page-switch uploads, and managed sample memory Jul 22, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5bb6e96367

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

FlushPendingOutlineUploads();
if (_ringOffset == 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispose queued batch resources on empty flush

When this empty-flush path is hit after ReplaceBatchBuffer has queued old outline buffers but before any ring dispatch has advanced _ringOffset (for example, the first oversized glyph after the outline buffers grow), it returns before draining _batchBuffers and _batchBindGroups. Those GPU resources remain referenced, and Dispose() does not clean these lists either, so large-font renders can leak the old outline buffers indefinitely unless a later non-oversized glyph happens to force a non-empty flush.

Useful? React with 👍 / 👎.

@wieslawsoltes

Copy link
Copy Markdown
Owner Author

Browser picker follow-up (42ceb47): browser open/save/folder pickers now use the File System Access API when available, with input/upload and download fallbacks. Save writes use FileSystemFileHandle.createWritable; selected handles are bounded to 32 entries and WASM byte buffers are snapshotted before asynchronous writes. Release/AOT validation covered open transfer, save write, folder selection, transient user activation, cancellation, and zero browser console errors. Focused Release tests: 19 passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42ceb4722b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +70 to +74
if (mode == 2)
{
var selection = ParseHandleSelection(result);
if (selection == null) return null;
return Path.Combine(FolderDirectory, selection.Value.Token, selection.Value.Name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire folder handles before returning browser folders

When the browser folder picker succeeds, this returns a synthetic /tmp/progpu-browser-folder/... path, but BrowserStorageServices.Initialize() still never installs EnumerateFilesAsync, EnumerateFoldersAsync, CreateFileAsync, or CreateFolderAsync, and the JS directoryHandles map is never used by C#. Any caller that uses the returned StorageFolder then falls back to local System.IO against a non-existent virtual path, so GetFilesAsync() reports an empty folder and CreateFileAsync() can throw instead of operating on the selected browser directory. Either keep returning null for mode 2 or add platform folder services backed by the directory handle.

Useful? React with 👍 / 👎.

@wieslawsoltes wieslawsoltes changed the title Reduce GPU text memory, page-switch uploads, and managed sample memory Reduce GPU text memory, page-switch and resize latency Jul 22, 2026
@wieslawsoltes

Copy link
Copy Markdown
Owner Author

Live-resize follow-up after manual validation:

  • Restored synchronous repainting from every framebuffer resize callback; Cocoa can hold the normal render loop during a native live drag, so deferring this work freezes visible updates.
  • Suppress only the paired scheduled frame when the retained root version is unchanged. A later application invalidation still renders normally.
  • Release Text & Documents resize workload: 299 framebuffer callbacks, 299 live frames, 299 duplicate scheduled frames suppressed, 120.45 visible wall FPS.
  • Focused Release regression suite: 7/7 passed; Release desktop build: 0 warnings/errors.

Implemented in b8fe4cf and hardened in cf96edf.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf96edfa51

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

: this(
context,
atlasSize,
Math.Min(atlasSize, DefaultColorAtlasSize),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep default color atlas growable for bitmap glyphs

With the default GlyphAtlas(context, 2048), this caps _maxColorAtlasSize at 512 even though the monochrome atlas can grow to 2048. In emoji-heavy text using bitmap-only color fonts such as CBDT/sbix, once the 512² color atlas fills, TryCreateColorBitmapGlyph returns false and the caller falls through to GetGlyphOutline, which is null for those fonts, so later emoji cache as empty glyphs instead of rendering. Keep the small initial color texture, but let the default maximum follow the configured glyph atlas size or add a bitmap-specific fallback so default text rendering does not drop color bitmap glyphs.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e058ae4c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2808 to +2809
i = lastWordIdx;
lineEnd = lastWordIdx;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip boundary spaces when wrapping multi-column lines

In LayoutMultiColumn, lastWordIdx is set to the index of the space/tab separator, so assigning i and lineEnd to that same index leaves the separator unconsumed and makes it the first character processed on the next line. For multi-column FlowDocument/Markdown paragraphs that wrap at word boundaries, wrapped lines therefore start with a leading space/tab, shifting the next word and distorting column fit/alignment; consume the separator (lastWordIdx + 1) or keep it on the completed line instead.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38f985723e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +295 to +297
if (!_isFlowLayoutDirty &&
Math.Abs(width - _lastFlowLayoutWidth) < 0.01f &&
Math.Abs(height - _lastFlowLayoutHeight) < 0.01f)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include padding in FlowDocument layout cache key

When Padding changes after the first layout and the next measure/arrange uses the same size, this cache hit returns before recomputing even though LayoutMultiColumn consumes Padding below. Since the PaddingProperty callback only updates the layout field and does not mark _isFlowLayoutDirty, text and embedded children keep their old inset until some other tracked property changes; include padding in this cache key or invalidate the flow layout when padding changes.

Useful? React with 👍 / 👎.

@wieslawsoltes
wieslawsoltes merged commit 12b905a into main Jul 22, 2026
6 checks passed
@wieslawsoltes
wieslawsoltes deleted the agent/compact-gpu-text-atlases branch July 22, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant