Repository navigation
perf: blend 8-bit image layers directly, index their visible runs, and add ToYCbCr - #14
Merged
Merged
Conversation
Add BenchmarkRenderImages and BenchmarkRenderImagesParallel, which render a print-size scene of eight 8-bit image layers (opaque panels, hollow frames and small strips in Normal, Multiply and Overlay modes) and report renders per second and allocations. They give the layer ingest and buffer reuse work a baseline to measure against. Rename the layered memory benchmarks and their scene to describe the scene rather than a card.
Add Layer.Image and Layer.LoadImage, which take an 8-bit image as a layer's content. An *image.NRGBA layer with no mask, effects or clip-to-below is blended straight into the document by the new blend.CompositeNRGBA, which reads each pixel through the sRGB table and skips transparent pixels, and in Normal mode writes opaque pixels without a multiply. Other image types, and layers that write to their content, are converted first. The result is bit-identical to converting the image with raster.FromImage and compositing the buffer, for every mode and opacity. Canvas never writes to an image, so one decoded image can be shared across layers and concurrent renders. On a print-size scene of eight image layers, one render goes from 7.0 to 29.4 per second and sixteen at once from 24.1 to 41.6, and allocation per render falls from 1.07 GB to 232 MB.
Add Buffer.ToYCbCr, which encodes a buffer as the 8-bit full-range BT.601 planes a JPEG stores, with chroma averaged over 2x2 blocks, so image/jpeg can write it without converting again. Alpha is dropped by flattening over an optional background color, black when it is missing. The conversion reads the sRGB code from a 64 KiB table, which is several times faster than the transfer function and can differ by one code from the exact encoding near a rounding boundary. It is not dithered. Convert ToImage's 8-bit and 16-bit output in parallel row bands, and add internal/parallel.RowsAligned for bands that keep 2x2 blocks whole. Output does not depend on GOMAXPROCS. On a 3264x4440 buffer, ToYCbCr takes 0.9 ms on 16 cores, and ToImage(8) goes from 34.7 ms on one core to 4.2 ms on 16.
Add blend.Index, which reads an *image.NRGBA once and records the runs of columns with any alpha in each row, merging runs less than 32 transparent pixels apart, and blend.CompositeIndexed, which blends only those runs. The result is bit-identical to CompositeNRGBA, since a pixel with no alpha leaves the backdrop alone in every mode. Indexed embeds the image, so a canvas layer takes one through Image or LoadImage unchanged and blends it directly. A layer that needs its content written to converts it as a plain image. On a 2048 by 2048 frame with a 60 pixel border, blending takes 0.94 ms against 3.26 ms. Building the index for a 3264 by 4440 image takes 7.5 ms once.
Move the image generators and the exact float comparison that the blend, canvas and raster tests each wrote their own copy of into internal/testimg. Cover the branches the image paths left open (opacity above one for an indexed image, an image too wide to convert, a transparent background for ToYCbCr, every sRGB code in blend's table), add tests for the raster buffer and ingest functions that had none, and add godoc examples for Index, CompositeNRGBA, ToYCbCr and Layer.Image. Drop Indexed.Runs, which nothing used.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds a way to give a layer an 8-bit image without converting it to a float buffer first, an index that lets a mostly transparent image skip its empty stretches, and a conversion from a buffer to the planes a JPEG stores. Nothing existing changes shape, and output from the existing paths is bit-identical.
Why
A program that composites many documents from decoded PNGs paid for a document-sized float32 buffer per layer per render, converting every pixel and then blending every pixel. On a 3264 by 4440 document that is 232 MB per layer. The profile of a downstream renderer at 16 workers put most of its time in that conversion and in allocation churn, and a further share in converting the finished buffer to 8 bits for a JPEG encoder that would convert it again.
What changes
Layer.ImageandLayer.LoadImagetake an 8-bit image as a layer's content, used whenContentandLoadare both nil. An*image.NRGBAwith no mask, effects or clip-to-below is blended straight into the document by the newblend.CompositeNRGBA, which reads each pixel through the sRGB table and does the same arithmetic as the buffer path. Any other image type, or a layer that has to write to its content, is converted first, so those results cannot change. Canvas never writes to an image, so one decoded image can be shared by any number of layers and concurrent renders.blend.Indexwraps an*image.NRGBAin ablend.Indexedthat records the runs of visible columns in each row, merging runs less than 32 transparent pixels apart.blend.CompositeIndexedblends only those runs.Indexedembeds the image, so it goes throughLayer.ImageandLoadImageunchanged. A single first and last column per row would not help a hollow frame, since every middle row has a border strip at each end, which is why the index keeps several runs per row.Buffer.ToYCbCr(background ...color.Color)encodes a buffer as the full-range BT.601 planes JPEG stores, with chroma averaged over 2x2 blocks, as an*image.YCbCrthatimage/jpegwrites without converting again. Alpha is flattened over the optional background, black when it is missing. It reads sRGB codes from a 64 KiB table, which is several times faster than the transfer function and can differ by one code from the exact encoding near a rounding boundary. It is not dithered, unlikeToImage.ToImagefor 8 and 16 bits now converts in parallel row bands, andinternal/parallelgainsRowsAlignedfor bands that keep 2x2 blocks whole. Output does not depend onGOMAXPROCS.Measurements
On an Apple M3 Max, on a print-size scene of eight image-backed layers:
ToImage(8), 3264 by 4440, 1 core / 16ToYCbCr, 3264 by 4440, 16 coresIn a downstream card renderer that hands its layers over as images, a parallel render and YCbCr conversion went from 14.8 cards/s to about 54 with direct blending, and to about 58 with the index. Impasto's own scene gains little from the index because its large opaque panels dominate.
Two ideas were built, measured and dropped, so they are not in this PR: reusing the backdrop buffer through a pool, which allocated 3.9 MB instead of 232 MB per render but made no measurable difference to speed, and limiting each blend's parallelism inside a batch, which was no faster than the default.
Testing
The direct and indexed paths are compared bit for bit with converting through
raster.FromImageand compositing the buffer, across all 16 blend modes, several opacities and origins, sub-images, and each fallback. A deliberate off-by-one in the run bounds fails those tests.ToYCbCris checked against an independent reference on even and odd sizes and over several backgrounds, andToYCbCrandToImageare checked to give the same result at differentGOMAXPROCSvalues. New godoc examples coverIndex,CompositeNRGBA,ToYCbCrandLayer.Image, and the docs forcanvas,blendandrasterdescribe the new behavior.The benchmarks added here,
BenchmarkRenderImagesand its direct, indexed and parallel variants, are the ones behind the table above. The existingcardSceneandBenchmarkMemoryCard*benchmarks were renamed to describe the scene rather than a card.Compatibility
All additions.
Layergains two fields,blendgainsCompositeNRGBA,Index,IndexedandCompositeIndexed, andrastergainsBuffer.ToYCbCr. The only visible change to existing behavior is thatToImageis faster.🤖 Generated with Claude Code