Slow redraws with noisy data in WPF

Oystein Bjorke fa 10 anys 0
This discussion was imported from CodePlex

tevo wrote at 2013-09-12 15:58:

This discussion was started in issue 10076. I'm going to reproduce the posts here for convenience.

tevo wrote at 2013-09-12 15:58:

My initial post:

I recently discovered a seemingly simple data set that slows down OxyPlot redraws to 1 or 2 FPS. I'm using OxyPlot.WPF 2013.1.67.1. I have a plot that takes up a full window and the window is nearly full screen (1680 x 1050). There is one LineSeries with 500 points generated like so:
const int n = 500;
var points = new List<IDataPoint>(n);
var rng = new System.Random();
for (int i = 0; i < n; i++)
    points.Add(new DataPoint(i + 1, rng.NextDouble()));
As I adjust the window size, OxyPlot redraw performance is very slow. This was quite surprising since I've plotted LineSeries with 10,000 points before and had much better performance. My only guess as to why it degrades so much is that the noisy data requires painting more pixels.

Does anyone have a better understanding of why performance is so bad in this case? Even better, is there a way to fix my code or OxyPlot?

tevo wrote at 2013-09-12 15:58:

objo's reply:

The code is added to the example library.
You can compare performance in WPF, SL and Windows Forms.
Also see the "Performance" examples using a zig-zag (sawtooth) curve.

Random data are difficult to optimize - all pixels must be drawn.
Are you binding to the data points using reflection?

tevo wrote at 2013-09-12 15:59:

I downloaded OxyPlot-2013.1.81.1.zip which has my test case in the example library. SilverLight redraws very quickly and Windows Forms is pretty fast as well. There was not a WPF version of the ExampleBrowser for me to compare.

I am not binding to the data points. My xaml just has this:
<oxy:Plot Model="{Binding PlotModel}" Title="OxyPlot" />
Then in C# I create the points and do
lineSeries.Points = points;
Regarding optimization: Does OxyPlot already skip data points in some cases or are you suggesting something new?

everytimer wrote at 2013-09-12 16:57:

tevo, how are you drawing those points? Are you using markers? If so, try to disable them, and use solid linestyle for your LineSeries. Also, reducing the physical size of your PlotModel could help a lot.

tevo wrote at 2013-09-12 17:05:

Thanks for the tips, everytimer, but I already have a solid line, no markers, and no grid lines. Reducing the screen size of my plot does improve redraw speed a lot, but I would like the plot to be nearly full screen so it's not a great solution for me.

everytimer wrote at 2013-09-13 21:15:

tevo, actually I have the same problem as you, but in my case it's not critical to redraw quickly the data. If speed is a must I would consider trying WinForms instead, it seems that WPF is not very optimized for plotting large sets of data. Good luck

tevo wrote at 2013-09-16 17:07:

After some more investigation I believe that WPF is to blame. OxyPlot is already optimizing performance by doing things like pruning the displayed data points and freezing brushes.

I profiled (sampling method) a very simple test case and found that the most expensive methods are:
  • Canvas.Children.Add() (13%) - called in ShapesRenderContext.Add(), which is called most often from ShapesRenderContext.DrawText()
  • Canvas.Children.Clear() (12%) - called in Plot.UpdateVisuals()
  • TextBlock.Measure() (11%) - called in ShapesRenderContext.DrawText() and ShapesRenderContext.MeasureText()
I highly doubt that anything can be done to reduce the number of Add() calls or speed up the Clear() call. It may be possible to reduce the number of Measure() calls by caching and reusing TextBlocks. I think TextBlocks are used most often for labeling ticks on axes so there could be a decent amount of reuse between updates.

There may be another way to address all of these problems at once but it would be a major change to OxyPlot. Instead of clearing the canvas and adding new objects for every update, existing objects could be updated and reused. This would eliminate calls to Canvas.Children.Clear(), reduce calls to Canvas.Children.Add() and TextBlock.Measure(), and reduce the number of objects that get created and destroyed. It would be a big change and I'm not even sure it would be a net win. I'd love to hear some thoughts on this idea, especially from objo.

For now, I'm going to seriously consider everytimer's suggestion to use WinForms. Performance is significantly better and probably acceptable for my app.

Lastly, I found some interesting links while looking into WPF performance. Maybe others will be interested as well...

objo wrote at 2013-09-17 00:03:

Thanks for the profiling results and good ideas, this is very interesting.

I have tried:
  • using DrawingContext (not much performance gain and problems with aliasing/antialiasing if I remember correctly)
  • disconnect Canvas from visual tree when updating (minor improvement, the DisconnectCanvasWhileUpdating property should be enabled in the Plot control)
  • setting CacheMode on the Canvas (does not look good)
Reusing UIElements is possible, but requires some refactoring of the render context class (e.g. clipped text uses an extra canvas container and these elements must be released from their parents before being added again).

Avoiding Add and Clear of the Canvas.Children collection would make a big difference - but it is difficult to avoid this since the order of the elements is important. I think this improvement needs some logic to insert new and remove old elements. I guess this is also needed in the major refactoring (retained graphics mode) you suggest.

Measurement of text should be avoided in the DrawText method, it should be possible to solve this by applying horizontal/vertical alignment only.

FontFamily could be cached - but I am not sure if this is noticeable.

Here is another interesting link:

tevo wrote at 2013-09-17 20:35:

Thanks, objo! I did some more tests based on your suggestions.
  1. Enabled DisconnectCanvasWhileUpdating. I couldn't see or measure any change in performance.
  2. Implemented a FontFamily cache (just like the brush cache). No change.
  3. Eliminate all calls to TextBlock.Measure() by commenting out the whole if{} block in DrawText(). Still no change!
  4. Turn off all ticks and tick labels on the axes. Still no change???
I guess this means that the methods I highlighted before are not actually that expensive. One thing I didn't mention was that System.Windows.Application.Run() was about 50% of exclusive samples. After the changes above, it's nearly 70%. That must be WPF doing what it does.

I think all of this means that optimizing the existing code just can't make that much of a difference. So what about switching to retained graphics mode? My tests don't give any data about that. Is there some way to test the concept without doing a major rewrite first?

a5r wrote at 2013-09-18 14:29:

I don't know really about your code but did you try out the following suggestions
http://msdn.microsoft.com/en-us/library/ee230083.aspx
and http://msdn.microsoft.com/en-us/library/ee230085.aspx

tevo wrote at 2013-09-18 16:15:

I tried using a BitmapCache for the canvas and as far as I could tell it did not affect visual quality or performance at all. I think that's because canvas.Children changes on every update, so the canvas must be redrawn every time and the cache is never used.

I also did another test where I let Plot.UpdateModelAndVisuals() run normally the first 10 times (just to get the plot drawn on screen), and after that always immediately returned. This is effectively a retained mode because the canvas and its children get reused for each frame. The plot was not changing at all but redraw performance was identical. Then I turned on the BitmapCache and redraws became nearly instant. The cache could do its job now because the canvas wasn't changing.

My conclusion is that WPF rendering is really slow in some cases, and any plot update requires WPF to render. I don't think there's any way to work around it.

Dang.

tevo wrote at 2013-09-19 16:03:

It seems that I spoke too soon about not being able to work around it... :)

I couldn't help myself and kept on playing with my simple test plot of random data between 0 and 1. I varied the number of points to find out if performance gradually decreased or if there was a cliff somewhere. On my machine, performance is good up to about 50 points and then degrades rapidly. 200 points is pretty darn bad.

Then for some reason I decided to try two series of 50 points and performance was still good. I added two more series of 50 points and performance only degraded a little. These four series with 200 total points performed much, much better than one series of 200 points. Interesting.

That made me look into what was actually being used to draw these lines. ShapesRenderContext.DrawLine() uses a System.Windows.Shapes.Polyline. I thought that maybe the number of points in a polyline was the limiting factor, so I updated the method to make multiple polylines with a limited number of points. On a test case with 500 points, this change results in a huge speedup. It's at least 10x faster redraws! I experimented with the number of points to allow in a polyline, and I think there was improvement all the way down to about 16.

So for my test case this change is unquestionably a huge improvement. I tested "LineSeries, 100k points" from the ExampleBrowser and unfortunately my change degraded performance a bit. I think that's because 100k / 16 = 6250 polylines which is a lot. I tried to come up with a way to balance the number of points per polyline and the number of polylines and it seems to be working well now. Here's the updated DrawLine() method:
public void DrawLine(
    IList<ScreenPoint> points,
    OxyColor stroke,
    double thickness,
    double[] dashArray,
    OxyPenLineJoin lineJoin,
    bool aliased)
{
    // balance the number of points per polyline and the number of polylines
    var numPointsPerPolyline = Math.Max(points.Count / MaxPolylinesPerLine, MinPointsPerPolyline);

    var polyline = new Polyline();
    this.SetStroke(polyline, stroke, thickness, lineJoin, dashArray, aliased);
    var pc = new PointCollection(numPointsPerPolyline);

    foreach (var p in points)
    {
        pc.Add(p.ToPoint(aliased));

        // use multiple polylines with limited number of points to improve WPF performance
        if (pc.Count >= numPointsPerPolyline)
        {
            polyline.Points = pc;
            this.Add(polyline);

            // start a new polyline at last point so there is no gap
            polyline = new Polyline();
            this.SetStroke(polyline, stroke, thickness, lineJoin, dashArray, aliased);
            var startPoint = pc.Last();
            pc = new PointCollection(numPointsPerPolyline);
            pc.Add(startPoint);
        }
    }

    if (pc.Count > 1 || points.Count == 1)
    {
        polyline.Points = pc;
        this.Add(polyline);
    }
}
And of course you also need to define:
/// <summary>
/// The maximum number of polylines per line.
/// </summary>
const int MaxPolylinesPerLine = 64;

/// <summary>
/// The minimum number of points per polyline.
/// </summary>
const int MinPointsPerPolyline = 16;
It was interesting to find that there is already a MaxFiguresPerGeometry variable. It is there there to "limit the number of figures, otherwise drawing errors...", but it seems like a similar thing.

Anyway, are there other things to think about and test or can this go straight into official OxyPlot?

objo wrote at 2013-09-19 23:20:

Very cool, I can also see a significant performance improvement.
The only problem is dashed lines, I think. See the new example.
Can you calculate the correct dash offset?

objo wrote at 2013-09-19 23:55:

I did some refactoring of the ShapesRenderContext. With the new CreateAndAdd method it should be possible to:
  • not clear the canvas, instead set a child element index = 0
  • when creating/adding: check if the canvas contains an element of the given type at the current index, reuse the element if type matches
  • otherwise create new instance and replace or add to the canvas
  • when finished, remove unused elements from canvas
This will only improve performance when the number and types of the elements do not change much...

tevo wrote at 2013-09-20 16:44:

I like your ideas for reusing elements. That should work great for resizing the plot and/or updating data points.

I took a crack at calculating dash offset and it's trickier than I expected. Maybe that's because I'm not sure how Shape.StrokeDashOffset works. MSDN documentation doesn't give details but I did find a book that explains things more. Below is my ugly test code that doesn't quite work yet.

Note that this code does not even try to handle a dashArray with odd length. That new example you added should probably have two lines to verify that even and odd length dash arrays work properly.
public void DrawLine(
    IList<ScreenPoint> points,
    OxyColor stroke,
    double thickness,
    double[] dashArray,
    OxyPenLineJoin lineJoin,
    bool aliased)
{
    // balance the number of points per polyline and the number of polylines
    var numPointsPerPolyline = Math.Max(points.Count / MaxPolylinesPerLine, MinPointsPerPolyline);

    double lineLength = 0;
    double dashPatternLength = (dashArray != null) ? dashArray.Sum() : 0;

    double[] dashArrayCumsum = null;
    if (dashArray != null)
    {
        dashArrayCumsum = new double[dashArray.Length + 1];
        for (int i = 0; i < dashArray.Length; i++)
        {
            dashArrayCumsum[i + 1] = dashArrayCumsum[i] + dashArray[i];
        }
    }

    var polyline = this.CreateAndAdd<Polyline>();
    this.SetStroke(polyline, stroke, thickness, lineJoin, dashArray, 0, aliased);
    var pc = new PointCollection(numPointsPerPolyline);

    var n = points.Count;
    var last = new Point();
    for (int i = 0; i < n; i++)
    {
        var p = points[i].ToPoint(aliased);
        pc.Add(p);

        if (dashArray != null)
        {
            if (i == 0)
            {
                last = p;
            }

            var delta = p - last;
            var dist = Math.Sqrt((delta.X * delta.X) + (delta.Y * delta.Y));
            lineLength += dist * thickness;
            lineLength %= dashPatternLength;
            last = p;
        }

        // use multiple polylines with limited number of points to improve WPF performance
        if (pc.Count >= numPointsPerPolyline)
        {
            polyline.Points = pc;

            if (i < n - 1)
            {
                // start a new polyline at last point so there is no gap
                polyline = this.CreateAndAdd<Polyline>();

                double dashOffset = 0;
                if (dashArray != null)
                {
                    for (int k = 1; k < dashArrayCumsum.Length; k++)
                    {
                        if (lineLength <= dashArrayCumsum[k])
                        {
                            dashOffset = (k - 1) + (lineLength - dashArrayCumsum[k - 1]) / (dashArrayCumsum[k] - dashArrayCumsum[k - 1]);
                            break;
                        }
                    }
                }

                this.SetStroke(polyline, stroke, thickness, lineJoin, dashArray, dashOffset, aliased);
                pc = new PointCollection(numPointsPerPolyline) { pc.Last() };
            }
        }
    }

    if (pc.Count > 1 || n == 1)
    {
        polyline.Points = pc;
    }
}

objo wrote at 2013-09-21 15:25:

It seems like the StrokeDashArray and StrokeDashOffset depends on the StrokeThickness. I tried the following in Kaxaml:
<Page
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Canvas>  
    <Line X1="100" Y1="100" X2="800" Y2="100" Stroke="Black" StrokeThickness="2" StrokeDashArray="10,5,20,10,30,15"/>
    <Line X1="110" Y1="110" X2="800" Y2="110" Stroke="Black" StrokeThickness="2" StrokeDashArray="10,5,20,10,30,15" StrokeDashOffset="5"/>
    <Line X1="130" Y1="120" X2="800" Y2="120" Stroke="Black" StrokeThickness="2" StrokeDashArray="10,5,20,10,30,15" StrokeDashOffset="15"/>
    <Line X1="150" Y1="130" X2="800" Y2="130" Stroke="Black" StrokeThickness="2" StrokeDashArray="10,5,20,10,30,15" StrokeDashOffset="25"/>
    <Line X1="250" Y1="140" X2="800" Y2="140" Stroke="Black" StrokeThickness="2" StrokeDashArray="10,5,20,10,30,15" StrokeDashOffset="75"/>
    <Line X1="350" Y1="150" X2="800" Y2="150" Stroke="Black" StrokeThickness="2" StrokeDashArray="10,5,20,10,30,15" StrokeDashOffset="125"/>
  </Canvas>
</Page>

tevo wrote at 2013-09-24 14:58:

Well the dash offset turned out to be simpler than I thought, which is usually how things go once you understand how something works.

http://i.imgur.com/IHc3PYh.png

Unfortunately, I also discovered a problem with using multiple Polylines to draw one line: LineJoins. The places where one Polyline ends and another begins can have artifacts. Here are some examples.
The thick lines look much worse than thin lines. On many thin lines I can't see any artifacts.

My only idea to overcome this issue is for Polylines to overlap by two points. That way the end of a Polyline will be covered by the first LineJoin of the next line. This might lead to other problems, however. If the line color is semi-transparent, the overlapping parts would appear to be a different color.

Any other ideas about what to do here?

Here's my current code:
public void DrawLine(
    IList<ScreenPoint> points,
    OxyColor stroke,
    double thickness,
    double[] dashArray,
    OxyPenLineJoin lineJoin,
    bool aliased)
{
    // balance the number of points per polyline and the number of polylines
    var numPointsPerPolyline = Math.Max(points.Count / MaxPolylinesPerLine, MinPointsPerPolyline);

    var polyline = this.CreateAndAdd<Polyline>();
    this.SetStroke(polyline, stroke, thickness, lineJoin, dashArray, 0, aliased);
    var pc = new PointCollection(numPointsPerPolyline);

    var n = points.Count;
    double lineLength = 0;
    var dashPatternLength = (dashArray != null) ? dashArray.Sum() : 0;
    var last = new Point();
    for (int i = 0; i < n; i++)
    {
        var p = points[i].ToPoint(aliased);
        pc.Add(p);

        if (dashArray != null)
        {
            if (i > 0)
            {
                var delta = p - last;
                var dist = Math.Sqrt((delta.X * delta.X) + (delta.Y * delta.Y));
                lineLength += dist;
            }

            last = p;
        }

        // use multiple polylines with limited number of points to improve WPF performance
        if (pc.Count >= numPointsPerPolyline)
        {
            polyline.Points = pc;

            if (i < n - 1)
            {
                // start a new polyline at last point so there is no gap
                polyline = this.CreateAndAdd<Polyline>();
                var dashOffset = (dashPatternLength > 0) ? (lineLength / thickness) % dashPatternLength : 0;
                this.SetStroke(polyline, stroke, thickness, lineJoin, dashArray, dashOffset, aliased);
                pc = new PointCollection(numPointsPerPolyline) { pc.Last() };
            }
        }
    }

    if (pc.Count > 1 || n == 1)
    {
        polyline.Points = pc;
    }
}

objo wrote at 2013-09-24 19:18:

Well done! I don't have any ideas to the problems you have found, it seems difficult to overcome this - particularly when we aim for performance.
I suggest we add a property on the Wpf Plot control: LineDrawingMode { HighQuality, HighSpeed }.

tevo wrote at 2013-09-25 19:46:

Adding LineDrawingMode seems like a reasonable thing to do. The default will be "HighQuality", correct?

In my earlier tests I didn't see artifacts in narrow lines. Perhaps we should always use "HighSpeed" mode when thickness is less than some value, say 3.5.

objo wrote at 2013-09-25 21:51:

I submitted the code with some minor changes!
I added a "BalancedLineDrawingThicknessLimit" property. The default value is 3.5, use 0 to get old method.
Can you send the code for the "Dashed Line test" example?

tevo wrote at 2013-09-25 22:58:

Looks good! Thanks for your help with this.

Here's the dashed line test code:
[Example("Dashed line test")]
public static PlotModel DashedLineTest()
{
    var model = new PlotModel("Dashed line test");

    for (int y = 1; y <= 24; y++)
    {
        var line = new LineSeries()
        {
            StrokeThickness = y,
            LineStyle = LineStyle.Dash,
            Dashes = new double[] { 1, 2, 3 } // has no effect
        };
        for (int i = 0; i < 20; i++)
            line.Points.Add(new DataPoint(i + 1, y));
        model.Series.Add(line);
    }

    return model;
}

objo wrote at 2013-09-25 23:32:

Thank you! Is the WPF performance good enough for your data now?
The NoisyData example seems to be much faster (10x ?).

tevo wrote at 2013-09-26 15:14:

Yes, performance is quite good even for my noisy data now. I benchmarked the noisy data test in the example browser, and it went from 5 to 15 FPS (only 3x), but it feels like so much more!