Search code examples
graphicsf#processingskiasharpskia

SkiaSharp performance worse than Processing?


Edit: The F# code has been updated such that it only uses the Silk.NET and SkiaSharp NuGet packages. It reproduces the same lower performance.

In Processing, you can find a performance example demonstrating the rendering of 50,000 lines in an 800x600 window in Examples -> Demos -> Performance -> LineRendering. This runs at 60fps.


public void setup() {
  size(800, 600, P2D);  
}
  
public void draw() {    
  background(255);
  stroke(0, 10);
  for (int i = 0; i < 50000; i++) {
    float x0 = random(width);
    float y0 = random(height);
    float z0 = random(-100, 100);
    float x1 = random(width);
    float y1 = random(height);
    float z1 = random(-100, 100);    
    
    // purely 2D lines will trigger the GLU 
    // tessellator to add accurate line caps,
    // but performance will be substantially
    // lower.
    line(x0, y0, x1, y1); // this line is modified from the example to use a 2D line
  }
  if (frameCount % 10 == 0) println(frameRate);
}

line rendering with Processing

And processing can maintain this 60fps performance up to 70,000 lines being drawn, only dropping off of 60fps after that.

I am using SkiaSharp with GLFW for windowing to do cross-platform windowing and graphics in F#. I wanted to check the performance of my windowing library, so I tried to replicate this Processing example. I was surprised that I couldn't hit 60fps, so I decided to drop down to using a raw for loop with just GLFW and SkiaSharp. To my surprise, the performance didn't improve at all, so it seems like the performance bottleneck is in SkiaSharp.

open FSharp.NativeInterop
open Silk.NET.GLFW
open SkiaSharp
#nowarn "9"

let width, height = 800, 600

let glfw = Glfw.GetApi()
glfw.Init() |> printfn "Initialized?: %A"

// Uncomment these window hints if on macOS
//glfw.WindowHint(WindowHintInt.ContextVersionMajor, 3)
//glfw.WindowHint(WindowHintInt.ContextVersionMinor, 3)
//glfw.WindowHint(WindowHintBool.OpenGLForwardCompat, true)
//glfw.WindowHint(WindowHintOpenGlProfile.OpenGlProfile, OpenGlProfile.Core)

let window = glfw.CreateWindow(width, height, "Test Window", NativePtr.ofNativeInt 0n, NativePtr.ofNativeInt 0n)
printfn "Window: %A" window
glfw.MakeContextCurrent(window)
let mutable error = nativeint<byte> 1uy |> NativePtr.ofNativeInt
glfw.GetError(&error) |> printfn "Error: %A"

let grGlInterface = GRGlInterface.Create(fun name -> glfw.GetProcAddress name)

if not(grGlInterface.Validate()) then
    raise (System.Exception("Invalid GRGlInterface"))

let grContext = GRContext.CreateGl(grGlInterface)
let grGlFramebufferInfo = new GRGlFramebufferInfo(0u, SKColorType.Rgba8888.ToGlSizedFormat()) // 0x8058
let grBackendRenderTarget = new GRBackendRenderTarget(width, height, 1, 0, grGlFramebufferInfo)
let surface = SKSurface.Create(grContext, grBackendRenderTarget, GRSurfaceOrigin.BottomLeft, SKColorType.Rgba8888)
let canvas = surface.Canvas
grContext.ResetContext()

let random = System.Random()

let randomFloat (maximumNumber: int) =
    (float (maximumNumber + 1)) * random.NextDouble()
    |> float32

// Setup up mutable bindings and a function to calculate the framerate
let mutable lastRenderTime = System.DateTimeOffset.Now.ToUnixTimeMilliseconds()
let mutable currentRenderTime = System.DateTimeOffset.Now.ToUnixTimeMilliseconds()
let mutable numberOfFrameRatesToAverage = 30
let mutable frameRates = Array.zeroCreate<float>(numberOfFrameRatesToAverage)
let mutable frameRateArrayIndex = 0

let calculateFrameRate () =
    lastRenderTime <- currentRenderTime
    currentRenderTime <- System.DateTimeOffset.Now.ToUnixTimeMilliseconds()
    let currentFrameRate = 1.0 / (float(currentRenderTime - lastRenderTime) / 1000.0)
    frameRates[frameRateArrayIndex] <- currentFrameRate
    frameRateArrayIndex <- (frameRateArrayIndex + 1) % numberOfFrameRatesToAverage
    (Array.sum frameRates) / (float numberOfFrameRatesToAverage)

let linePaint = new SKPaint(Color = SKColor(0uy, 0uy, 0uy, 10uy))

let frameRatePaint = new SKPaint(Color = SKColor(byte 0, byte 0, byte 0, byte 255))
frameRatePaint.TextSize <- 50.0f

while not (glfw.WindowShouldClose(window)) do
    glfw.PollEvents()
    
    canvas.Clear(SKColors.WhiteSmoke)

    for _ in 1..50_000 do
        canvas.DrawLine(
            SKPoint(randomFloat <| int width, randomFloat <| int height),
            SKPoint(randomFloat <| int width, randomFloat <| int height),
            linePaint)
    let frameRate = calculateFrameRate()

    canvas.DrawText(sprintf "%.0f" frameRate, 10.0f, 50.0f, frameRatePaint)

    canvas.Flush()
    glfw.SwapBuffers(window)
        
glfw.DestroyWindow(window)
glfw.Terminate()

line rendering in F# with GLFW and SkiaSharp

As you can see, the performance ranges between 40-50fps. I am quite surprised by this, and I can't see how my code differs substantially from what Processing seems to be doing. I thought it may be sprintf holding things back, but I replaced it with string and various other things, which didn't seem to affect it. Also, the performance will definitely increase if I reduce the number of lines drawn, so I don't think sprintf is the bottleneck here due to that.

What is going on here? Is it just the simple case that Processing (and thus the underlying Java and Java graphics) are faster than F# and SkiaSharp?

I am using F# and .NET 7, the latest SkiaSharp, and GLFW 3. For processing, I am using a recent version of Processing 4. These tests were done on a laptop with a 12th Gen i7 and NVIDIA RTX 3050.


Solution

  • I was able to run sample with some search. Source can be found in gist. There are 4 points I think are relevant:

    1. When multiple GPUs are available (common for notebooks) by default it uses integrated graphics (in my case Intel HD630) instead of more performant (RTX 2060). To change GPU, I had to use NVidia control panel.

    2. Performance seems not to be lost in user code. Things like sprintf or calculateFrameRate doesn't appear during CPU profiliration.

    3. GPU utilization is quite low - at ~20%.

    4. Most of the time is spent inside DrawLine. From stacktraces it seems that most time is spent in queueing OpenGL operations.

    I would say that there are some problems in SkiaSharp