Skip to content

Skia: avoid SKBitmap.Copy when creating ImmutableBitmap from pixels - #21675

Merged
MrJul merged 10 commits into
masterfrom
fixes/immutable-bitmap-from-pixels-perf
Jul 2, 2026
Merged

MrJul merged 10 commits into
masterfrom
fixes/immutable-bitmap-from-pixels-perf

Conversation

@kekekeks

@kekekeks kekekeks commented Jun 30, 2026 •

Copy link
Copy Markdown
Member

Copy pixel data into manually allocated buffer instead of calling SKBitmap.Copy() which internally uses SKCanvas for some reason.

│ Size │ Copy (baseline) │   AllocBlit   │ Ratio │ Alloc: Copy → AllocBlit │
│   16 │        7,955 ns │        724 ns │ 0.09× │           784 B → 216 B │
│   64 │       43,858 ns │      1,089 ns │ 0.02× │           784 B → 216 B │
│  256 │      616,802 ns │      7,698 ns │ 0.01× │           784 B → 216 B │
│ 1024 │   11,719,249 ns │    249,043 ns │ 0.02× │           784 B → 216 B │
│ 4096 │  211,303,378 ns │ 36,149,041 ns │ 0.17× │           784 B → 216 B │

Also introduces a fast-path for CopyPixels and fixes a buffer size check to account for potential overflow.

The pixel-data ImmutableBitmap constructor used by LoadBitmap(IntPtr) wrapped
the caller's buffer with InstallPixels and then called SKBitmap.Copy() to get
an owned copy. SKBitmap.Copy() internally spins up an SKCanvas and draws the
source via an SKPaint shader, which is far more expensive than a memory blit.

Instead, allocate the destination with Marshal.AllocHGlobal, blit the pixels
span-to-span, and hand ownership to Skia via InstallPixels with a release proc
that frees the buffer. This is dramatically faster (~6x at 4096x4096, 50-80x at
small/medium sizes) and allocates less managed memory.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@kekekeks kekekeks added area-perf backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch labels Jun 30, 2026
@Gillibald

Copy link
Copy Markdown
Contributor

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0066979-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@kekekeks

Copy link
Copy Markdown
Member Author

To skip awkwardness with per-row copying, BitmapMemory enforces tight layout. I guess it would technically be a better approach, yes.

kekekeks and others added 2 commits June 30, 2026 17:00
…-row copy

Follow-up to the previous change. Two corrections/improvements:

* Use BitmapMemory as the backing storage instead of a bare Marshal.AllocHGlobal
  buffer, so allocation/lifetime (incl. GC memory pressure) is managed in one
  place. Ownership is handed to Skia via InstallPixels with a release proc that
  disposes the BitmapMemory.

* The previous single contiguous blit was incorrect: the source stride is allowed
  to be negative (bottom-up layouts). Copy row by row instead, which also handles
  the backing store's row alignment differing from the source stride.

To avoid duplicating the row-copy logic, Bitmap.CopyPixelsCore is now an
internal static helper taking the source as raw (address, rowBytes, format),
reused by both Bitmap/WriteableBitmap.CopyPixels and ImmutableBitmap. Source-rect
validation moves to the (instance) call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) 
Re-add the instance CopyPixelsCore(..., ILockedFramebuffer fb) overload that
validates the source rect and delegates to the static raw-buffer core. Inheritors
(WriteableBitmap) and the framebuffer-based CopyPixels paths use this self-contained
overload again, while ImmutableBitmap keeps using the static raw overload directly.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@kekekeks

Copy link
Copy Markdown
Member Author

kekekeks and others added 4 commits June 30, 2026 17:34
When the source and destination layouts are identical, tightly-packed and forward
(no row padding, no X offset, positive stride == minStride), the whole region is
contiguous in both buffers, so copy it with a single blit instead of the per-row
loop. Measured up to ~5x faster for small images and ~30% for large ones; requiring
stride == minStride also guarantees we never read past the source's last row.
Negative strides, sub-rect copies and padded layouts still take the per-row path.

Co-Authored-By: Claude Opus 4.8 (1M context) 
* BitmapMemory: make ReleaseUnmanagedResources idempotent (zero Address after
  FreeHGlobal). SKBitmap.InstallPixels invokes the release proc even when it
  returns false, so the failure branch in ImmutableBitmap could dispose the same
  BitmapMemory twice -> double free / heap corruption. Idempotency also guards any
  other native-handoff consumer that double-disposes.

* Bitmap.CopyPixelsCore: compute minBufferSize in 64-bit so a very large
  stride*height can't overflow the buffer-size guard and let the new contiguous
  blit (or the per-row loop) run past the buffers.

* ImmutableBitmap: dispose the SKBitmap if SKImage.FromBitmap returns null so the
  backing memory is freed promptly via the release proc instead of waiting for the
  finalizer.

Co-Authored-By: Claude Opus 4.8 (1M context) 
Back Address with a field and claim it via Interlocked.Exchange in
ReleaseUnmanagedResources, so the buffer is freed exactly once even if an
explicit Dispose() races with the finalizer or a native release callback running
on another thread.

Co-Authored-By: Claude Opus 4.8 (1M context) 
Co-Authored-By: Claude Opus 4.8 (1M context) 
@kekekeks
kekekeks marked this pull request as draft June 30, 2026 18:12
kekekeks and others added 2 commits June 30, 2026 18:14
Since CopyPixelsCore copies into the destination, ImmutableBitmap can simply
TryAllocPixels a Skia-owned SKBitmap and blit into it, instead of allocating a
separate BitmapMemory and handing it to InstallPixels with a release proc. This
removes the manual unmanaged-memory lifetime management (and the
release-proc/double-dispose hazard along with it). SKBitmap frees its own pixels
on Dispose.

Reverts the now-unneeded BitmapMemory idempotency/Interlocked change too, since
that code path no longer exists.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@kekekeks
kekekeks marked this pull request as ready for review June 30, 2026 18:18
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0066999-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@MrJul
MrJul added this pull request to the merge queue Jul 2, 2026
Merged via the queue into master with commit 842937d Jul 2, 2026
11 checks passed
@MrJul
MrJul deleted the fixes/immutable-bitmap-from-pixels-perf branch July 2, 2026 14:18
@MrJul MrJul removed the backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch label Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants