14

I am working on an iOS App that visualizes data as a line-graph. The graph is drawn as a CGPath in a fullscreen custom UIView and contains at most 320 data-points. The data is frequently updated and the graph needs to be redrawn accordingly – a refresh rate of 10/sec would be nice.

So far so easy. It seems however, that my approach takes a lot of CPU time. Refreshing the graph with 320 segments at 10 times per second results in 45% CPU load for the process on an iPhone 4S.

Maybe I underestimate the graphics-work under the hood, but to me the CPU load seems a lot for that task.

Below is my drawRect() function that gets called each time a new set of data is ready. N holds the number of points and points is a CGPoint* vector with the coordinates to draw.

- (void)drawRect:(CGRect)rect {

    CGContextRef context = UIGraphicsGetCurrentContext();

    // set attributes
    CGContextSetStrokeColorWithColor(context, [UIColor lightGrayColor].CGColor);
    CGContextSetLineWidth(context, 1.f);

    // create path
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddLines(path, NULL, points, N+1);

    // stroke path
    CGContextAddPath(context, path);
    CGContextStrokePath(context);

    // clean up
    CGPathRelease(path); 
}

I tried rendering the path to an offline CGContext first before adding it to the current layer as suggested here, but without any positive result. I also fiddled with an approach drawing to the CALayer directly but that too made no difference.

Any suggestions how to improve performance for this task? Or is the rendering simply more work for the CPU that I realize? Would OpenGL make any sense/difference?

Thanks /Andi

Update: I also tried using UIBezierPath instead of CGPath. This post here gives a nice explanation why that didn't help. Tweaking CGContextSetMiterLimit et al. also didn't bring great relief.

Update #2: I eventually switched to OpenGL. It was a steep and frustrating learning curve, but the performance boost is just incredible. However, CoreGraphics' anti-aliasing algorithms do a nicer job than what can be achieved with 4x-multisampling in OpenGL.

Community
  • 1
  • 1
Andi Wagner
  • 310
  • 2
  • 9
  • Your color is a constant. Move it out if drawRect and keep reusing it rather than asking for a new one each time. Ditto for the path. Since StrokePath() "empties the path", you could reuse the same path object over and over. What does that change? – verec Jan 03 '12 at 17:45
  • The documentation claims that the path is emptied, but at least in my code it is not. It just keeps growing. As for the color, you have a point. That was bad, but not the solution. Thanks. – Andi Wagner Jan 03 '12 at 18:10
  • 1
    Same problem here. Seems to me that Apple's CGPath implementation on iOS is abysmally slow - as soon as you go beyond 20-30 paths, performance plummets ... for *no reason at all*. Same hardware, same dataset, I get 20x the performance if I do a shoddy re-implementation in OpenGL. I have no idea what Apple's doing, but it seems to be very wrong :(. – Adam Mar 19 '12 at 18:09
  • Since you are drawing a graph, did you have to flip the rect upside down in order for the zero to down and the positive numbers to be up? I had to do this, and I find it cost me about 5-10% of CPU. CGContextTranslateCTM(ctx, 0.0, rect.size.height/2); CGContextScaleCTM(ctx, 1.0, -1.0); – Van Du Tran Jan 22 '14 at 17:36

5 Answers5

9

This post here gives a nice explanation why that didn't help.

It also explains why your drawRect: method is slow.

You're creating a CGPath object every time you draw. You don't need to do that; you only need to create a new CGPath object every time you modify the set of points. Move the creation of the CGPath to a new method that you call only when the set of points changes, and keep the CGPath object around between calls to that method. Have drawRect: simply retrieve it.

You already found that rendering is the most expensive thing you're doing, which is good: You can't make rendering faster, can you? Indeed, drawRect: should ideally do nothing but rendering, so your goal should be to drive the time spent rendering as close as possible to 100%—which means moving everything else, as much as possible, out of drawing code.

Community
  • 1
  • 1
Peter Hosey
  • 95,783
  • 15
  • 211
  • 370
  • Peter, thanks for this answer! I understand that rendering is what I ask the CPU to do and what it is doing. I was only surprised to occupy it _this_ much. I think, I'll give OpenGL a shot and see if it can help me offload some work onto the GPU. – Andi Wagner Jan 03 '12 at 23:00
  • I have the same issue.. and I do have to create a new CGPath everytime a new point comes in.. and that's 128 times per seconds – Van Du Tran Jan 22 '14 at 17:30
6

Depending on how you make your path, it may be that drawing 300 separate paths is faster than one path with 300 points. The reason for this is that often the drawing algorithm will be looking to figure out overlapping lines and how to make the intersections look 'perfect' - when perhaps you only want the lines to opaquely overlap each other. Many overlap and intersection algorithms are N**2 or so in complexity, so the speed of drawing scales with the square of the number of points in one path.

It depends on the exact options (some of them default) that you use. You need to try it.

Tom Andersen
  • 7,132
  • 3
  • 38
  • 55
  • This is an amazing observation. I was drawing one single UIBezierPath to draw ~4 line charts with 20000 points each and it was taking about 4 seconds. By splitting into 20000 bezierpaths it came down to ~0.1 seconds – Cortex Jan 30 '20 at 17:54
3

tl;dr: You can set the drawsAsynchronously property of the underlying CALayer, and your CoreGraphics calls will use the GPU for rendering.

There is a way to control the rendering policy in CoreGraphics. By default, all CG calls are done via CPU rendering, which is fine for smaller operations, but is hugely inefficient for larger render jobs.

In that case, simply setting the drawsAsynchronously property of the underlying CALayer switches the CoreGraphics rendering engine to a GPU, Metal-based renderer and vastly improves performance. This is true on both macOS and iOS.

I ran a few performance comparisons (involving several different CG calls, including CGContextDrawRadialGradient, CGContextStrokePath, and CoreText rendering using CTFrameDraw), and for larger render targets there was a massive performance increase of over 10x.

As can be expected, as the render target shrinks the GPU advantage fades until at some point (generally for render target smaller than 100x100 or so pixels), the CPU actually achieves a higher framerate than the GPU. YMMV and of course this will depend on CPU/GPU architectures and such.

ldoogy
  • 2,819
  • 1
  • 24
  • 38
  • CALayers by definition live on the GPU and as such "use the GPU for rendering". CALayers are essentially an abstraction above a GPU texture. So I'm curious where it's documented that they're drawn by the CPU when drawsAsynchronously is false? – John Scalo Dec 05 '20 at 19:09
  • @JohnScalo This is more of an observation than a documented behavior. CALayers do live on the GPU, but CoreGraphics calls do not. Those are rendered on the CPU by default. In my experience, if you set the drawsAsynchronously flag on the CALayer, it will then return a CGContext that lets CoreGraphics render on the GPU (when it calls your renderInContext:). – ldoogy Dec 21 '20 at 19:58
0

Have you tried using UIBezierPath instead? UIBezierPath uses CGPath under-the-hood, but it'd be interesting to see if performance differs for some subtle reason. From Apple's Documentation:

For creating paths in iOS, it is recommended that you use UIBezierPath instead of CGPath functions unless you need some of the capabilities that only Core Graphics provides, such as adding ellipses to paths. For more on creating and rendering paths in UIKit, see “Drawing Shapes Using Bezier Paths.”

I'd would also try setting different properties on the CGContext, in particular different line join styles using CGContextSetLineJoin(), to see if that makes any difference.

Have you profiled your code using the Time Profiler instrument in Instruments? That's probably the best way to find where the performance bottleneck is actually occurring, even when the bottleneck is somewhere inside the system frameworks.

Andrew Madsen
  • 21,309
  • 5
  • 56
  • 97
  • I updated my post with a comment about NSBezierPath and tweaking the Line parameters. Time Profiler shows that most calories (83%) are being burned inside aa_render which is part of CG. – Andi Wagner Jan 03 '12 at 17:37
  • (Sorry, I hit return after the first sentence ;)) – Andi Wagner Jan 03 '12 at 17:39
0

I am no expert on this, but what I would doubt first is that it could be taking time to update 'points' rather than rendering itself. In this case, you could simply stop updating the points and repeat rendering the same path, and see if it takes nearly the same CPU time. If not, you can improve performance focusing on the updating algorithm.

If it IS truly the problem of the rendering, I think OpenGL should certainly improve performance because it will render all 320 lines at the same time in theory.

barley
  • 4,403
  • 25
  • 28
  • Updating the data is not the problem (I checked in Time Profiler). I also avoided rendering the same path over and over again, since in real-life the data is changing, too. And I didn't want to make CG's life easy by just updating the same bitmap ;) At this point, I am fighting to avoid OpenGL, but if I have to... – Andi Wagner Jan 03 '12 at 17:43