1

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.

bmitc
  • 357
  • 1
  • 9
  • Can you link used nuget packages? Cannot find wrapper that have Windowing.GLFW namespace – JL0PD Mar 13 '23 at 04:34
  • The `Windowing` package is my own package and is not currently published on NuGet and is in a private repository at the moment. However, it is only used to create and manage the window. At the level of this example, all that's used are very simple F# wrappers over direct bindings to GLFW. The same example could be done using something else, such as Silk.NET. Maybe I'll post something. – bmitc Mar 13 '23 at 08:02
  • It falls into [no minimal complete verifiable example](http://idownvotedbecau.se/nomcve/). I guess question will be closed due to lack of ability to reproduce. Publish library on github, so it can be cloned and inspected – JL0PD Mar 13 '23 at 08:24
  • It is very common to have non-running code on StackOverflow. This question has basically nothing to do with the windowing library used, so anyone can use whatever they want. SkiaSharp comes with built-in views. However, I'll try and create a Silk.NET example and update my question. – bmitc Mar 13 '23 at 08:46
  • The F# code example has been replaced with code that is directly reproducible using the Silk.NET and SkiaSharp NuGet packages. It still uses GLFW and has the same performance that is less than that of Processing. – bmitc Mar 14 '23 at 03:35
  • 1
    I've posted answer based on older code, it seems that bottleneck is SkiaSharp – JL0PD Mar 14 '23 at 03:54

1 Answers1

1

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

JL0PD
  • 3,698
  • 2
  • 15
  • 23
  • Thank you for taking a look! Especially updating the code before I had a chance to upload the new code with Silk.NET! For (1), I was ready to say that that wasn't my experience, but before commenting, I tested and see that I see the same thing as well. The Intel GPU is used instead of the NVIDIA, although I see no improvement in performance when switching to the NVIDIA card. I also could have sworn that the NVIDIA card was used before, as I distinctly remember running both Processing and my code at the same time and seeing the NVIDIA GPU pegged until I closed my code. This is weird, for sure. – bmitc Mar 14 '23 at 20:45
  • For (2), that matched my intuition and testing, and for (3), I also note that the GPU utilization is low. It's about 50% of the GPU utilization of the Processing code. For (4), this is interesting. It actually sounds like this may be a Skia performance issue and not SkiaSharp, since SkiaSharp is a C# wrapper of Skia. – bmitc Mar 14 '23 at 20:47
  • What are you using to profile this code with? I have struggled to use profilers before with .NET, but it sounds like you're seeing the OpenGL calls as well. – bmitc Mar 14 '23 at 20:48
  • I've used [concurrency visualizer for VS2022](https://learn.microsoft.com/en-us/visualstudio/profiling/concurrency-visualizer). It shows exact stacktraces of different threads and also GPU activity in a timeline – JL0PD Mar 15 '23 at 00:29
  • Thanks very much for that reference! I hadn't heard of that particular tool before and will give it a try. – bmitc Mar 15 '23 at 14:39