In practice Chapter 31 of 35

Performance with large data

What a chart spends its time on when it draws, which settings buy the most of it back, and how to measure instead of guess.

A chart with fifty thousand entries is not automatically slow. What decides the frame time is how much of that data is on screen, and how much text and how many shapes the renderer has to put down for it.

Only the entries in view are drawn#

Before a data set is drawn, the renderers work out which entries are visible from the same two properties you can read yourself:

val low = chart.lowestVisibleX
val high = chart.highestVisibleX

The entries just outside the screen are included so the line does not stop at the edge, and the renderers walk only that stretch. So the visible range decides the cost of a frame, not the total. Fifty thousand entries with two hundred on screen cost about what two hundred cost. Zooming out is the expensive direction, which is why capping it helps:

chart.setVisibleXRangeMaximum(200f)
chart.moveViewToX(0f)

Both are computed from the current x axis range, so they have to run after the data is set. In Compose that means the update lambda, not setup, which runs once before any data exists. Modifying the viewport has the rest of the window settings and Compose explains the two lambdas.

A bar chart works the same way: only the visible entries are fed into its BarBuffer. What is still sized from the total entry count is the buffer itself, which costs memory rather than frame time.

Measured draw times#

Every number below comes from the same run: an Android emulator, API 36, arm64, on an Apple Silicon Mac, with the whole data set on screen and nothing else running. The chart draws into an offscreen bitmap, so the figure is the chart's own draw and not a whole frame, and each size runs in a process of its own, because a warming compiler and a growing heap otherwise hide the trend. Each figure is the median of nine draws after warmups. Decimation is off, which is the default.

Each cell gives the draw time and then the frame rate that draw alone allows, which is 1000 divided by the milliseconds. It is a ceiling rather than a promise: a real frame still has to measure, lay out and composite, and the display caps it at 60 or 120 whatever the chart does. Read it as headroom. Anything under 60 means the chart cannot hold a smooth frame at that size on this device, and anything under 30 will be visible as stutter while panning.

Entries on screenLineBar
1,0001.7 ms · 588 fps4.3 ms · 233 fps
10,0009.1 ms · 110 fps33.5 ms · 30 fps
50,00039.8 ms · 25 fps156.6 ms · 6 fps
100,00076.4 ms · 13 fps310.3 ms · 3 fps
Draw time by entries on screen, in milliseconds Line Bar 0 80 160 240 320 1,000 · Line: 1.7 ms 1.7 1,000 · Bar: 4.3 ms 4.3 1,000 10,000 · Line: 9.1 ms 9.1 10,000 · Bar: 33.5 ms 33.5 10,000 50,000 · Line: 39.8 ms 39.8 50,000 · Bar: 156.6 ms 156.6 50,000 100,000 · Line: 76.4 ms 76.4 100,000 · Bar: 310.3 ms 310.3 100,000

A line costs about 0.76 microseconds per entry and a bar about 3.1, because a line is one batched call to the canvas and a bar chart is one call per bar. That ratio, not the absolute numbers, is the thing to carry to your own hardware.

Decimation, and when it is worth it#

Decimation drops entries that would land in the same pixel column, keeping the first, lowest, highest and last of each, so peaks survive. It is off by default: the reduction pass reads every visible entry itself, so it only pays when the drawing it saves is more expensive than the reading it adds.

50,000 entriesOffOn
Plain line, one color39.8 ms62.4 ms
Line with a color per segment147.3 ms62.2 ms
Bars156.6 ms60.8 ms
Draw time at 50,000 entries with decimation off and on, in milliseconds Decimation off Decimation on 0 40 80 120 160 Plain line,|one colour · Decimation off: 39.8 ms 39.8 Plain line,|one colour · Decimation on: 62.4 ms 62.4 Plain line, one colour Line, a colour|per segment · Decimation off: 147.3 ms 147.3 Line, a colour|per segment · Decimation on: 62.2 ms 62.2 Line, a colour per segment Bars · Decimation off: 156.6 ms 156.6 Bars · Decimation on: 60.8 ms 60.8 Bars

Turn it on with chart.isDecimationEnabled = true when a lot of entries are on screen at once and each costs a draw call of its own: a bar chart, a scatter or bubble chart, a line with a color per segment. At 100,000 bars it is the difference between 310 ms and 115 ms. Leave it off for a plain single color line, where it costs about half as much again as it saves.

Against 3.1.0#

The same scenarios, measured the same way on the same device, against the last Java release. Both sides draw the same picture: same data, same chart size, same viewport, no value labels, no circles. Decimation has no equivalent in 3.1.0, so 4.0 runs with it off here.

Scenario3.1.04.0
Line, 10,00010.7 ms9.1 ms1.18x faster
Line, 50,00046.2 ms39.8 ms1.16x faster
Line, 100,00091.4 ms76.4 ms1.20x faster
Bars, 50,00097.9 ms156.6 ms1.60x slower
Bars, 100,000176.0 ms310.3 ms1.76x slower
Bars, 50,000, decimation on97.9 ms60.8 ms1.61x faster
Bars, 100,000, decimation on176.0 ms114.5 ms1.54x faster
The same draw in 3.1.0 and 4.0, in milliseconds 3.1.0 4.0 0 80 160 240 320 Line|10,000 · 3.1.0: 10.7 ms 10.7 Line|10,000 · 4.0: 9.1 ms 9.1 Line 10,000 Line|50,000 · 3.1.0: 46.2 ms 46.2 Line|50,000 · 4.0: 39.8 ms 39.8 Line 50,000 Line|100,000 · 3.1.0: 91.4 ms 91.4 Line|100,000 · 4.0: 76.4 ms 76.4 Line 100,000 Bars|50,000 · 3.1.0: 97.9 ms 97.9 Bars|50,000 · 4.0: 156.6 ms 156.6 Bars 50,000 Bars|100,000 · 3.1.0: 176.0 ms 176.0 Bars|100,000 · 4.0: 310.3 ms 310.3 Bars 100,000 Bars 100,000|decimation on · 3.1.0: 176.0 ms 176.0 Bars 100,000|decimation on · 4.0: 114.5 ms 114.5 Bars 100,000 decimation on

Lines are about a fifth quicker. Bars at their full extent are slower than they were, which is worth saying plainly rather than leaving out: a 4.0 bar chart that draws every entry does more work than 3.x did. Decimation more than covers it, and then some, but it has to be asked for. If you draw tens of thousands of bars at once, turn it on.

What to expect on a phone#

A 60 Hz frame is 16.67 ms and a chart realistically gets about half of it. On the numbers above that is roughly 10,000 line entries or 2,500 bars on the emulator, and a phone is slower again: expect a recent high-end device to be 2 to 3 times slower and a mid-range one 3 to 5 times.

As a cross check away from the bitmap, the example app's performance screen with 30,000 points on a real hardware accelerated surface renders whole frames at a median of 32 ms while panning, which is the same order as the 24 ms the bitmap measurement implies for that size. The two ways of measuring agree.

These figures replace earlier ones in this chapter that were measured differently and could not be reproduced.

Against other charting libraries#

The Compose charting libraries that come up most often, measured the same way on the same device. Every library gets a view of the same size and the same entries, and is configured to show the whole series at once rather than the scrolling window most of them show by default. The ranges below cover what five runs of each produced, one library per process, with the frame rate each draw time allows beside it.

Library10,00050,000
MPAndroidChart 4.08-10 ms · 100-125 fps30-40 ms · 25-33 fps
KoalaPlot 0.12.113-17 ms · 59-77 fps60-75 ms · 13-17 fpsabout 2x slower
Vico 2.5.220-25 ms · 40-50 fps85-90 ms · 11-12 fpsabout 2.5x slower
YCharts 2.1.030-36 ms · 28-33 fps170-210 ms · 5-6 fps4x to 5x slower
Draw time by charting library, in milliseconds 10,000 entries 50,000 entries 0 50 100 150 200 MPAndroidChart|4.0 · 10,000 entries: 8-10 ms 8-10 MPAndroidChart|4.0 · 50,000 entries: 30-40 ms 30-40 MPAndroidChart 4.0 KoalaPlot|0.12.1 · 10,000 entries: 13-17 ms 13-17 KoalaPlot|0.12.1 · 50,000 entries: 60-75 ms 60-75 KoalaPlot 0.12.1 Vico|2.5.2 · 10,000 entries: 20-25 ms 20-25 Vico|2.5.2 · 50,000 entries: 85-90 ms 85-90 Vico 2.5.2 YCharts|2.1.0 · 10,000 entries: 30-36 ms 30-36 YCharts|2.1.0 · 50,000 entries: 170-210 ms 170-210 YCharts 2.1.0

Take it as an order of magnitude rather than a league table, which is why the numbers above are ranges. The four do not draw quite the same picture: the pixels each one covered differ by up to half again, KoalaPlot draws a much denser grid than the rest, and none of them was tuned beyond being told to show everything.

Two things about measuring this produced convincing nonsense before they were caught, and both are worth knowing if you run your own comparison. A chart that has not been handed its data yet covers an empty canvas in microseconds, which reads as a spectacular result, so a run has to prove it put something on screen before its time counts. And a library drawn for the first time since it was installed came out four times slower than on its next run, because the device keeps what it compiled between launches.

Value labels are the most expensive thing on screen#

Every drawn label runs your value formatter and then Canvas.drawText, far more work per entry than a line segment, so the library has a brake built in. On the charts with axes, a data set draws its labels only while at most chart.maxVisibleCount of its entries are inside the visible x range.

Two details are easy to get wrong:

  • The count is per data set and only covers the entries in view. A chart with several sets, or one that holds 5,000 entries but shows 80 of them, still draws its labels.
  • maxVisibleCount is 100 by default, so a set with 500 entries on screen shows no labels until the user zooms in far enough to bring the count down to 100.

When you never want labels, say so on the data set rather than relying on the count. Both of these default to true:

set.isDrawValuesEnabled = false
set.isDrawIconsEnabled = false
Pie and radar charts report their own entry count as maxVisibleCount, so the check always passes and labels are always drawn. There the data set switches are the only way to turn them off.

Circles, and why a large radius costs more#

A line data set draws a circle at every visible entry unless you say otherwise:

set.isDrawCirclesEnabled = false

Circles are stamped from one cached bitmap per circle color, rebuilt only when the colors or the radii change, so steady data costs nothing extra. A line also draws at most one circle per circle width along the x axis: once the points are closer together than a circle is wide, the circles in between would be covered anyway and are skipped. That happens whether decimation is on or not, so a dense line with circles costs about what its width allows, not what its entry count asks for. The radius matters twice over: each bitmap is circleRadius * 2.1 pixels square, and each stamp blends that many pixels onto the canvas, so doubling it roughly quadruples both. The default is 4 dp, and values below 1 are refused.

Curve mode and the offscreen bitmap#

LineDataSet.Mode.LINEAR and STEPPED fill one reused float array with the visible segments and hand the whole thing to a single Canvas.drawLines call. CUBIC_BEZIER and HORIZONTAL_BEZIER build a Path with one cubicTo per segment, which is more work per entry and cannot be batched the same way.

set.mode = LineDataSet.Mode.LINEAR

A linear line with more than one color also loses the batching, because each segment is drawn in its own call so it can carry its own color.

Both curve modes and any dashed line draw onto an offscreen bitmap the size of the chart, which the renderer composes onto the canvas at the end of the pass; a plain solid linear line goes straight onto the chart canvas instead. Dashing is the cheapest thing here to give up, with set.disableDashedLine().

Hardware acceleration#

One property switches the view between a hardware and a software layer:

chart.isHardwareAccelerationEnabled = true

That is all it does. A fresh chart has no layer at all, so the property reads false even though the window is already hardware accelerated.

Worth keeping apart from that property is whether the drawing reaches the GPU, because a chart drawn to a hardware accelerated window and one drawn to a software Canvas behave nothing alike. A window is hardware accelerated by default: the draw is recorded into a display list and the GPU rasterises it, so one batched call is nearly free however many points it covers. On a software canvas, which is what toBitmap() and a software layer give you, the CPU fills every pixel itself. That is exactly why reducing the point count can fail to help.

A hardware layer earns its keep only for a chart whose content does not change while the chart itself moves, because the view is rendered into a texture once and reused while you scroll or fade it. A chart that invalidates on every frame redraws into that texture anyway and only pays for it.

Clipping#

PropertyDefaultWhat it does
isClipDataToContentEnabledtrueClips the data, the grid lines and the highlights to the content rectangle
isClipValuesToContentEnabledfalseClips the value labels as well

Turn the first off when a thick line or a large shape near the edge is being cut in half. The second costs one canvas save and restore per frame, which is nothing next to drawing the labels themselves, so turn it on for looks rather than for speed.

Thin the data before you draw it#

When a series has more points than the screen has pixels, most of them cannot be seen. Every chart with an x axis can leave those out while it draws. It is off by default:

chart.isDecimationEnabled = true

While it is on, the renderers keep the first, the lowest, the highest and the last entry of each pixel column and skip the rest, so peaks and troughs survive and the shape stays the same. A bubble chart also keeps the largest bubble of each column. Your data is untouched; this happens on the way to the canvas.

Whether it is worth its own cost depends entirely on what one entry costs to draw, and the numbers above say it plainly. At 50,000 entries a line with a color per segment goes from 147.3 ms to 62.2 ms and a bar chart from 156.6 ms to 60.8 ms, because in both a single entry is a draw call of its own. A plain line goes the other way, from 39.8 ms to 62.4 ms, because there the reduction pass costs more than the one batched call it saves. Turn it on for large bar, scatter and bubble charts and for a line with more than one color; leave it off, as it comes, for a plain line.

Two cases are handled without the switch. Line circles that another circle would cover are always skipped, as described above. A candlestick chart thins itself once more than two candles share a pixel column on average, keeping the first, lowest, highest and last candle of each column, because every candle costs several draw calls and a crowded one cannot be read anyway.

Approximator is the other way to thin a series, once while you build the data rather than on every draw. It takes a flat FloatArray of x0, y0, x1, y1, ... and a tolerance, reduces it with the Douglas-Peucker algorithm, and returns the same layout:

val reduced = Approximator().reduceWithDouglasPeucker(points, 2f)

The tolerance is in the same unit as the points you pass in. Nothing in the library calls it for you, so run it on a background thread while the data is being built.

Do not allocate while drawing#

The renderers run inside onDraw, and the library goes out of its way not to allocate there: the buffers are refilled in place, and MPPointF, MPPointD and FSize come from shared ObjectPool instances.

Those buffers are sized from the total entry count rather than the visible one, so fifty thousand points on a single colored line reserve about 1.6 MB for as long as the set is assigned. That is the real cost of a large total count: memory, not frame time.

Your own code is the other half:

  • Formatters. getFormattedValue is called for every drawn label on every frame. Build the NumberFormat or SimpleDateFormat once as a property of the formatter, never inside the function. Formatters shows the shape.
  • Markers. refreshContent runs before every marker draw, so for a marker that follows a drag it runs per frame. Keep the views and change only their content. Markers has the details.

Building the data is plain object work with no view involved, so do it on a background thread. Only assigning chart.data and calling chart.notifyDataSetChanged() have to happen on the main thread.

Measure, do not guess#

Every chart can write its internals to logcat under the tag MPAndroidChart:

chart.isLogEnabled = true

On the axis charts the useful line is printed at the end of each draw:

Drawtime: 6 ms, average: 7 ms, cycles: 42

chart.resetTracking() zeroes the total and the cycle count, which is what you call right before the interaction you actually want to time.

The example app has two screens built for exactly this, Line chart performance and Bar chart performance. Each shows what the chart's own draw cost, the worst frame of the last second, and how many entries are on screen, with switches for the entry count, decimation, value labels and the label limit, plus the line shape, circles, fill and a color per segment on one and stacking, rounded corners and bar shadows on the other. The tables above come from them.

Two caveats. Logging itself costs time on every draw, so the numbers are a comparison between settings rather than an absolute. And the draw time does not include notifyDataSetChanged, which is where the axis recalculation happens. For anything beyond a rough comparison, record a trace with the Android Studio profiler.

The settings that pay off most#

Roughly in order of what they buy, with the measured figures where there are any. All of them are emulator numbers.

SettingWhat it buys
chart.setVisibleXRangeMaximum(n)The biggest lever there is. Cost follows the points on screen, not the points you hold: 10,000 on screen draws in 9.1 ms where 100,000 takes 76.4 ms. A million point set scrolls perfectly well at a sensible zoom.
A single color on a line set39.8 ms against 147.3 ms at 50,000 entries. One color is one batched draw call; a color per segment is a call per segment.
set.isDrawValuesEnabled = falseRemoves the text pass. chart.maxVisibleCount decides the zoom at which labels start appearing, so raising it costs you the same way.
set.isDrawCirclesEnabled = falseRemoves the circle pass. Covered circles are already skipped, so this matters most while the points are spread out.
chart.isDecimationEnabled = trueOff as it comes. At 50,000 entries it takes bars from 156.6 ms to 60.8 ms and a line with a color per segment from 147.3 ms to 62.2 ms. It helps scatter and bubble charts for the same reason. It costs a plain line 39.8 ms against 62.4, so leave it off there.
set.mode = LineDataSet.Mode.LINEAROne batched call instead of a path with a segment per entry.
set.disableDashedLine()Takes the line off the offscreen bitmap pass.
Leaving isDrawFilledEnabled offA filled line rebuilds a path in chunks of 128 entries every frame.
chart.isDrawBarShadowEnabled = falseOne fewer rectangle per bar.
Batching appendsA live feed that calls notifyDataSetChanged() per entry pays about 0.4 ms each time. Append what arrived, then notify once.

None of these is worth applying blindly. Turn on isLogEnabled, note the average, change one thing, and compare.

Where to go next#