Startsidan  ▸  Texter  ▸  Teknikblogg

Anders Hesselbom

Programmerare, skeptiker, sekulärhumanist, antirasist.
Författare till bok om C64 och senbliven lantis.
Röstar pirat.

Det finns enorma optimeringsmöjligheter när man använder GDI från .NET

2023-07-17

När man ska jobba med bitmapsgrafik i .NET är prestanda en ständigt återkommande utmaning. I .NET har vi dels tillgång till GDI (som låter oss rita pixlar individuellt) och något som kallas för GDI+, som erbjuder möjligheten att rita geometriska figurer på skärmen. GDI är numera en del av GDI+, så båda biblioteken exponeras i namnrymden System.Drawing. Den viktigaste klassen för den som jobbar med GDI heter Bitmap och den viktigaste klassen för den som jobbar med GDI+ heter Graphics.

Som parentes vill jag nämna att .NET inte är ett ramverk utan två. Dels har vi .NET Framework och dels har vi det ramverk som kort och gott heter .NET (tidigare .NET Core). Jag väljer i princip alltid .NET (tidigare .NET Core) eftersom det är ett mer moget ramverk, det finns tillgängligt på flest operativsystem (Mac, Linux, Android, m.fl.) och för att du får arbeta i senare versioner av C# i .NET. Det enda motivet att välja .NET Framework är att appar byggda för .NET Framework 4.8 fungerar out-of-the-box på Windows 10 eller senare och uppdateras via Windows Update. Eftersom GDI är en Windows-feature är jag inte intresserad av plattformsoberoendet och väljer därför .NET Framework 4.8 och C# 7.3 för dessa exempel.

Använder man Visual Studio med tillägget för skrivbordsapplikationer, är det bara att välja en projektmall för Windows Forms och .NET Framework, men kör man Visual Studio Code eller någon annan editor, måste man specificera i projektfilen att man vill ha TargetFrameworkVersion satt till v4.8, att OutputType ska vara WinExe och att namnrymderna System.Windows.Forms och System.Drawing ska vara refererade.

Låt oss titta på detta program. Det ritar en rektangel (500×500 pixlar) på skärmen varje gång man klickar med musen. Rektangeln ritas röd- och gulrandig på en slumpvis vald plats. Hela operationen klockas.

using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        private static Random _rnd = new Random();
        private Bitmap Rectangle { get; set; }
        private int PositionX { get; set; }
        private int PositionY { get; set; }

        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_MouseClick(object sender, MouseEventArgs e)
        {
            PositionX = _rnd.Next(300);
            PositionY = _rnd.Next(300);

            // Städa upp minnet.
            Rectangle?.Dispose();

            Rectangle = new Bitmap(500, 500);

            // Rita en randig rektangel.
            var c1 = Color.FromArgb(255, 0, 0);
            var c2 = Color.FromArgb(255, 255, 0);

            var stopwatch = new Stopwatch();
            stopwatch.Start();

            for (var y = 0; y < 500; y++)
            {
                var currentColor = y%2 == 0 ? c1 : c2;

                for (var x = 0; x < 500; x++)
                {
                    Rectangle.SetPixel(x, y, currentColor);
                }
            }

            // Skriv ut hur lång tid operationen tog.
            stopwatch.Stop();
            Text = stopwatch.ElapsedMilliseconds.ToString();

            // Uppdatera skärmen (Form1_Paint kommer att anropas).
            Invalidate();
        }

        private void Form1_Paint(object sender, PaintEventArgs e)
        {
            if (Rectangle == null)
                return;

            // Här utförs uppdateringen av skärmen.
            e.Graphics.Clear(Color.White);
            e.Graphics.DrawImage(Rectangle, PositionX, PositionY);
        }

        private void Form1_FormClosed(object sender, FormClosedEventArgs e)
        {
            // Städa upp minnet.
            Rectangle?.Dispose();
        }
    }
}

Hela operationen tar ungefär 140 millisekunder oavsett om programmet är debug- eller release-kompilerat.

Funktionen SetPixel är central för att detta ska fungera. Den har Microsoft skrivit åt oss i C#, och använder i sin tur GDI i Windows för att få pixeln satt på skärmen. Om vi läser källkoden så ser vi att den gör en del tester som säkerställer att funktionen används korrekt, för att sedan anropa GdipBitmapSetPixel i biblioteket gdiplus.dll som är skrivet i C++.

Vi skulle kunna optimera bort testerna, men jag förstår att Microsoft fill ha med dem, eftersom de tänker att det måste vara bättre med ett program som är lite slöare än ett program som kraschar om någon gör ett fel, som t.ex. att försöka rita en pixel på en bitmapsbild vars position ligger utanför bildens storlek.

En annan mindre viktig detalj är att för varje pixel som ritas, tar SetPixel en färg som är en struktur av en röd, grön och blå byte, som ska konverteras till en int. Konverteringen är gjord i förväg av färgstrukturen, men det blir ett extra funktionsanrop och en extra typomvandling.

Dessa optimeringar får man såklart gärna titta på, om man vill få ut så hög prestanda man bara kan i sin renderingsrutin. Men den viktigaste faktorn handlar om låsning av minnet. När man ritar pixlar ska man erhålla ett lås för att skydda minnet, som man sedan släpper när man är färdig. Som SetPixel är implementerad, skapas och släpps ett lås för varje pixel som sätts, vilket kostar. Att göra en egen implementation som håller låset tills alla pixlar är ritade, är inte särskilt svårt, men det ställer lite högre krav på den som använder implementationen. Och det kanske var därför som Microsoft valde den väg man valde – säkerhet framför prestanda. Trots allt är tanken med C# att det ska vara mer lättillgängligt än t.ex. C++.

Ett exempel på denna lättillgänglighet är att C#-kompilatorn helt enkelt inte tillåter användandet av pointers såvida man inte först deklarerar att man tänker göra så, vilket man gör med nyckelordet unsafe. Det är till och med så att nyckelordet unsafe inte får användas om vi inte sätter AllowUnsafeBlocks till true i projektfilen!

För att undvika att minnet blir låst för varje pixel som ritas, måste vi undvika den inbyggda funktionen SetPixel. Och om vi manipulerar minnet utan att använda den inbyggda funktionen SetPixel, måste vi manuellt låsa minnet när vi börjar arbeta och låsa upp det när vi är klara.

Funktionen LockBits behöver kunna räkna ut hur mycket minne som ska låsas och behöver därför veta bildens storlek (500×500 pixlar) samt vilket pixelformat som används. Jag utnyttjar bara 24 bitar (röd x grön x blå) men arbetar med 32 bitar (alfa x röd x grön x blå), så därför anger jag pixelformatet Format32bppArgb. Svaret från funktionen LockBits används för att berätta för funktionen UnlockBits vad som ska låsas upp, så det vill man ta hand om.

Sist men inte minst måste färgen på pixlarna sättas. Tidigare var det bara ytterligare en parameter till SetPixel, men nu måste även det göras manuellt. Eftersom vi arbetar i 24 bitar, kan vi använda en fyra bytes stor pointer, Scan0.

using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        private static Random _rnd = new Random();
        private Bitmap Rectangle { get; set; }
        private int PositionX { get; set; }
        private int PositionY { get; set; }

        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_MouseClick(object sender, MouseEventArgs e)
        {
            PositionX = _rnd.Next(300);
            PositionY = _rnd.Next(300);

            // Städa upp minnet.
            Rectangle?.Dispose();

            var bits = new int[500 * 500];
            var bitsHandle = GCHandle.Alloc(bits, GCHandleType.Pinned);
            Rectangle = new Bitmap(500, 500, 500 * 4,
                PixelFormat.Format32bppArgb,
                bitsHandle.AddrOfPinnedObject());

            // Rita en randig rektangel.
            var c1 = Color.FromArgb(255, 0, 0).ToArgb();
            var c2 = Color.FromArgb(255, 255, 0).ToArgb();

            var stopwatch = new Stopwatch();
            stopwatch.Start();

            var data = Rectangle.LockBits(
                new Rectangle(0, 0, 500, 500),I
                mageLockMode.ReadWrite,
                PixelFormat.Format32bppArgb);

            unsafe
            {
                var bytes = (byte*)data.Scan0;

                for (var y = 0; y < 500; y++)
                {
                    for (var x = 0; x < 500; x++)
                    {
                        var index = x + y * 500;
                        bits[index] = y % 2 == 0 ? c1 : c2;
                    }

                    bytes++;
                }
            }

            Rectangle.UnlockBits(data);
            bitsHandle.Free();

            // Skriv ut hur lång tid operationen tog.
            stopwatch.Stop();
            Text = stopwatch.ElapsedMilliseconds.ToString();

            // Uppdatera skärmen (Form1_Paint kommer att anropas).
            Invalidate();
        }

        private void Form1_Paint(object sender, PaintEventArgs e)
        {
            if (Rectangle == null)
                return;

            // Här utförs uppdateringen av skärmen.
            e.Graphics.Clear(Color.White);
            e.Graphics.DrawImage(Rectangle, PositionX, PositionY);
        }

        private void Form1_FormClosed(object sender, FormClosedEventArgs e)
        {
            // Städa upp minnet.
            Rectangle?.Dispose();
        }
    }
}

Och skillnaden går inte av för hackor! Den lite mer komplicerade koden till trots, nu går samma operation på en (!) millisekund! Man kan alltså lugnt konstatera att om prestanda är viktigt, så bör man kringgå funktionen SetPixel.

Categories: C#

Leave a Reply

Your email address will not be published. Required fields are marked *



En kopp kaffe!

Bjud mig på en kopp kaffe (20:-) som tack för bra innehåll!

Bjud på en kopp kaffe!

Om...

Kontaktuppgifter, med mera, finns här.

Följ mig

Twitter Instagram
GitHub RSS

Public Service

Folkbildning om public service.

Hem   |   linktr.ee/hesselbom   |   winsoft.se   |   80tal.se   |   Filmtips