Insane WPF Scatter Chart performance with Parallel Rendering
Published: 2024-05-02 09:05:54

Today one of our first ever customers (dan danna naah!) contacted us after 3-years and asked about our Scatter Chart performance. They are still using v1.x of SciChart and wanted to know if it was possible with the latest version to draw a million points in a scatter chart at interactive framerates, and what performance improvements we had achieved over the years.

<em>This was a chance for us to show off, I thought!! :-)</em>

Well, over the major versions we have focused on performance (see our article <a href="https://www.scichart.com/how-fast-is-scichart-wpf-chart/" target="_blank" rel="noopener">How Fast is SciChart WPF Chart</a>). SciChart has gotten faster over the major versions as you can see below and in the article above:

<a href="https://www.scichart.com/wp-content/uploads/2015/07/ScatterPerformance.png"><img class="alignnone size-large wp-image-11697" src="https://www.scichart.com/wp-content/uploads/2015/07/ScatterPerformance-1024x805.png" alt="ScatterPerformance" width="1024" height="805" /></a>

Scatter performance in our tests has improved from about 1.8 FPS for 250k points in v1.7 to 12FPS in v3.2, about a 6x speed improvement. This is good, but not quite good enough, the customer would like to draw 1,000,000 points in a scatter chart ....
<h2>Parallel Rendering for Scatter Charts</h2>
An old FAQ on the SciChart forums show a <a href="https://www.scichart.com/questions/question/faster-high-density-scatter-charting" target="_blank" rel="noopener">quick hack to enable multi-threaded rendering</a> for the <a href="https://www.scichart.com/documentation/webframe.html#Abt.Controls.SciChart.Wpf~Abt.Controls.SciChart.Visuals.PointMarkers.EllipsePointMarker.html" target="_blank" rel="noopener">EllipsePointMarker</a> type. This gives a decent boost in scatter chart performance on dual and quad core machines.

Unfortunately the sample no longer compiles, it only works with SciChart v3.1. So we took this as a starting point and re-worked it for SciChart v3.4.
<h4>A small update to the SciChart BasePointMarker API</h4>
We needed to add a small enhancement (backward compatible) to the <a href="https://www.scichart.com/documentation/webframe.html#Abt.Controls.SciChart.Wpf~Abt.Controls.SciChart.Visuals.PointMarkers.BasePointMarker.html" target="_blank" rel="noopener">BasePointMarker</a> type. This has been added to v3.4.2.6726 and above which you can get from our <a href="http://support.scichart.com/index.php?/Knowledgebase/Article/View/17232/37/getting-nightly-builds-with-nuget" target="_blank" rel="noopener">NuGet feed</a>. This allows us to be notified in the pointmarker when a batch begins and ends and do some caching.
<h2>A new ParallelEllipsePointMarker type</h2>
With our new slightly modified API, we can create a new ParallelEllipsePointMarker type that takes advantage of the Begin/End batching.
<pre class="brush: csharp">public class ParallelEllipsePointMarker : BasePointMarker
{
private float _width;
private float _height;
private readonly List&lt;Point&gt; _points = new List&lt;Point&gt;();
private IPen2D _pen;
private IBrush2D _brush;

/// &lt;summary&gt;
/// When overridden in a derived class, draws the point markers at specified collection of &lt;see cref="Point" /&gt; centers
/// &lt;/summary&gt;
/// &lt;param name="context"&gt;The RenderContext to draw with&lt;/param&gt;
/// &lt;param name="centers"&gt;The Centres of the point markers&lt;/param&gt;
/// &lt;param name="pen"&gt;The default Stroke pen (if current pen is not set)&lt;/param&gt;
/// &lt;param name="brush"&gt;The default Fill brush (if current brush is not set)&lt;/param&gt;
/// &lt;seealso cref="IRenderContext2D" /&gt;
/// &lt;seealso cref="IPen2D" /&gt;
/// &lt;seealso cref="IBrush2D" /&gt;
protected override void DrawInternal(IRenderContext2D context, IEnumerable&lt;Point&gt; centers, IPen2D pen, IBrush2D brush)
{
Action&lt;Point&gt; drawOp = (center) =&gt;
{
context.DrawEllipse(pen, brush, center, _width, _height);
};

// This will only work for some drawing functions, like DrawEllipse. Consider this experimental
Parallel.ForEach(centers, drawOp);
}

protected override void DrawInternal(IRenderContext2D context, double x, double y, IPen2D pen, IBrush2D brush)
{
_pen = pen;
_brush = brush;
_points.Add(new Point(x, y));
}

public override void Begin(IRenderContext2D context, IPen2D defaultPen, IBrush2D defaultBrush)
{
base.Begin(context, defaultPen, defaultBrush);

_width = (float) Width;
_height = (float) Height;
_points.Clear();
}

public override void End(IRenderContext2D context)
{
DrawInternal(context, _points, _pen, _brush);
base.End(context);
}
}
</pre>
The above class keeps a List of Points which is cleared on Begin() and drawn on End(). Every call to DrawInternal with a single x,y point is added to the Points. In End() we draw all points using Parallel.ForEach.
<h4>Performance of ParallelEllipsePointMarker</h4>
The performance of the ParallelEllipsePointMarker is better than the standard EllipsePointMarker. Take a look at the below. While our standard point-marker achieves barely 4.5FPS for 1,000,000 points, the Parallel Ellipse Point Marker is able to push out around 15FPS on an i7 Quad-core workstation.

<a href="https://www.scichart.com/wp-content/uploads/2015/07/ParallelEllipsev3.4.21.gif"><img class="alignnone size-full wp-image-11700" src="https://www.scichart.com/wp-content/uploads/2015/07/ParallelEllipsev3.4.21.gif" alt="ParallelEllipsev3.4.2" width="792" height="588" /></a>
<h4>Overdraw and Contention</h4>
This is good but there is some overdraw. Imagine if you had 1,000,000 scatter points drawn all at the same X,Y location. The above implementation would be grossly inefficient. Also, the Parallel.ForEach spawns goodness knows how many threads and context switching between them will start to become an overhead. Having fewer threads active will be a good thing for performance. Ideally, there should be no more threads than there are CPUs.
<h2>ClusteredParallelEllipsePointMarker</h2>
What about clustering and parallel drawing? ... Whoa there! Don't get too excited!

We had a go at a simple, almost lossless clustered parallel point marker. See the code below:
<pre class="brush: csharp">public class ClusteredParallelEllipsePointMarker : BasePointMarker
{
private float _width;
private float _height;
private IPen2D _pen;
private IBrush2D _brush;
private byte[,] _pointsDrawn;
private int _viewportWidth;
private int _viewportHeight;

/// &lt;summary&gt;
/// When overridden in a derived class, draws the point markers at specified collection of &lt;see cref="Point" /&gt; centers
/// &lt;/summary&gt;
/// &lt;param name="context"&gt;The RenderContext to draw with&lt;/param&gt;
/// &lt;param name="centers"&gt;The Centres of the point markers&lt;/param&gt;
/// &lt;param name="pen"&gt;The default Stroke pen (if current pen is not set)&lt;/param&gt;
/// &lt;param name="brush"&gt;The default Fill brush (if current brush is not set)&lt;/param&gt;
/// &lt;seealso cref="IRenderContext2D" /&gt;
/// &lt;seealso cref="IPen2D" /&gt;
/// &lt;seealso cref="IBrush2D" /&gt;
protected override void DrawInternal(IRenderContext2D context, IEnumerable&lt;Point&gt; centers, IPen2D pen, IBrush2D brush)
{
// This method is ignored by v3.2 and up...
}

protected override void DrawInternal(IRenderContext2D context, double x, double y, IPen2D pen, IBrush2D brush)
{
_pen = pen;
_brush = brush;
int ix = (int)x;
int iy = (int)y;

// If out of bounds, skip
if (ix &lt; 0 || iy &lt; 0 || ix &gt;= _viewportWidth || iy &gt;= _viewportHeight)
return;

// If pixel already marked as ignored, skip
if (_pointsDrawn[ix, iy] == 0xFF)
return;

// Set pixel as marked to draw
_pointsDrawn[ix, iy] = 0x1;

// Below here
//
// This introduces a very very slight loss by also marking the surrounding pixels to a Point-Marker as
// ignored (using 0xFF). We will prevent drawing to these pixels as well
// Comment out this section below if you wish to have a true lossless clustered parallel pointmarker
//

// Set surrounding pixels as marked to ignore
if (ix &lt; 1 || iy &lt; 1 || ix &gt;= _viewportWidth-1 || iy &gt;= _viewportHeight-1)
return;

_pointsDrawn[ix - 1, iy - 1] = 0xFF; // x x x
_pointsDrawn[ix - 1, iy] = 0xFF; // x o x
_pointsDrawn[ix - 1, iy + 1] = 0xFF; // x x x
_pointsDrawn[ix, iy - 1] = 0xFF;
_pointsDrawn[ix, iy + 1] = 0xFF; // when a point is drawn at o, do not allow any points to be drawn at x
_pointsDrawn[ix + 1, iy - 1] = 0xFF;
_pointsDrawn[ix + 1, iy] = 0xFF;
_pointsDrawn[ix + 1, iy + 1] = 0xFF;
}

public override void Begin(IRenderContext2D context, IPen2D defaultPen, IBrush2D defaultBrush)
{
base.Begin(context, defaultPen, defaultBrush);

_viewportWidth = (int)context.ViewportSize.Width;
_viewportHeight = (int)context.ViewportSize.Height;

if (_pointsDrawn == null || _pointsDrawn.GetLength(0) != _viewportWidth ||
_pointsDrawn.GetLength(1) != _viewportHeight)
{
_pointsDrawn = new byte[_viewportWidth, _viewportHeight];
}
else
{
Array.Clear(_pointsDrawn, 0, _pointsDrawn.Length);
}
_width = (float)Width;
_height = (float)Height;
}

public override void End(IRenderContext2D context)
{
Parallel.For(0, _viewportHeight, y =&gt;
{
for (int x = 0; x &lt; _viewportWidth; x++)
{
if (_pointsDrawn[x, y] == 0x1)
{
context.DrawEllipse(_pen, _brush, new Point(x,y), _width, _height);
}
}
});

base.End(context);
}
}
</pre>
<h4>So how does it work?</h4>
On Begin() we declare a Byte array the same size as the viewport. This byte array, any point-marker to draw we set a byte to 0x1. Any point-marker to ignore will remain 0x0.

For extra credit, we can optionally set points surrounding an 0x1 (point to draw) with 0xFF: a point to ignore. This means that if two or more scatter points are to be drawn within a 1-pixel distance of each other, only one scatter point will be drawn, reducing overdraw.

Second, because we're using a Byte array and not a List of points, we're avoiding memory creation on each pass. The Byte array is only recreated if the viewport is resized.

Finally, we use Parallel.For to iterate over rows of the byte array. Sure there is some cache missing here as we're reading from all over the byte array, but at least we have a maximum of one Task or thread per row, not one per pixel :)
<h4>Performance of the ClusteredParallelEllipsePointMarker</h4>
The performance is pretty insane. Take a look at the below. While our standard <a href="https://www.scichart.com/documentation/webframe.html#Abt.Controls.SciChart.Wpf~Abt.Controls.SciChart.Visuals.PointMarkers.EllipsePointMarker.html" target="_blank" rel="noopener">EllipsePointMarker</a> achieves barely 4.5FPS for 1,000,000 points, the ClusteredParallelEllipsePointMarker above is able to push out a whopping ~30FPS on an i7 Quad-core workstation.

<a href="https://www.scichart.com/wp-content/uploads/2015/07/ClusteredParallelEllipsev3.4.2.gif"><img class="alignnone size-full wp-image-11699" src="https://www.scichart.com/wp-content/uploads/2015/07/ClusteredParallelEllipsev3.4.2.gif" alt="ClusteredParallelEllipsev3.4.2" width="792" height="588" /></a>
<h2>A note about DirectX</h2>
The attached sample allows you to compare HighSpeed (CPU) vs. DirectX scatter performance. If you run it, you'll probably be disappointed that DirectX is considerably slower than a parallel enabled CPU rendering.* We've done some analysis and can see some low-hanging fruit to vastly improve the DirectX rendering of scatter charts. Note that<a href="https://www.scichart.com/how-fast-is-scichart-wpf-chart/" target="_blank" rel="noopener"> DirectX is considerably faster for line charts</a> however.
<blockquote>* Update: SciChart v4 now has <a href="https://www.scichart.com/directx-scatter-chart-performance-fixed/">vastly improved DirectX Scatter Chart performance</a> compared to SciChart v3</blockquote>
Also note that Parallel-enabled point markers are only compatible with the HighSpeed (CPU) renderer. The other renderers are not suitable to parallelise on the CPU, although DirectX by its very nature is highly parallel and we will be working to improve it.
<h2>Conclusion</h2>
If ClusteredParallelEllipsePointMarker was able to achieve near 30FPS at 1,000,000 points, but our previous speed test clocked SciChart v3.2's EllipsePointMarker at 4.5FPS points and SciChart v1.7's point-marker at 1.8FPS for 250k points then our solution is approx ~60x faster at drawing scatter charts than SciChart v1.x.

<em>So, how did we do? ;-)</em>

<strong>Download the sample below</strong>

<a href="https://www.scichart.com/wp-content/uploads/2015/07/ParallelPointMarkers.zip">ParallelPointMarkers</a>

Requires SciChart v3.4.2.6726 or above to run.

To test performance, run in Release mode with the debugger Detached!

&nbsp;