Skip to content

The plot function of the matplotlib 2 and 3 versions is much slower than 1.5.3 #12542

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
materia9 opened this issue Oct 17, 2018 · 19 comments · Fixed by #13593
Closed

The plot function of the matplotlib 2 and 3 versions is much slower than 1.5.3 #12542

materia9 opened this issue Oct 17, 2018 · 19 comments · Fixed by #13593
Milestone

Comments

@materia9
Copy link

Bug report

Bug summary

The plot function of the matplotlib 2 and 3 versions is much slower than the plot function of matplotlib 1.5.3 if one plots many lines (and other objects) in an Axes.

Code for reproduction

I have tested it on an Ubuntu server (CLI only), version 16.04 with python 3.5.2 (through virtualenv), matplotlib 1.5.3 with backend agg set by matplotlibrc.

setup="""import matplotlib.pyplot as plt
a=[1,2]"""
test="plt.plot(a,a,'-')"
import timeit
timeit.timeit(test,setup,number=1000)
# output: 0.6324263589922339
timeit.timeit(test,setup,number=1000)
# output: 0.6100882450118661
timeit.timeit(test,setup,number=1000)
# output: 0.6113728249911219

=> around 0.6-0.7 sec, it is stable for a few dozen times.

On the same machine & operating system, python 3.5.2 (through virtualenv), matplotlib 2.2.2 with backend agg.

setup="""import matplotlib.pyplot as plt
a=[1,2]"""
test="plt.plot(a,a,'-')"
import timeit
timeit.timeit(test,setup,number=1000)
# output: 0.9981246230890974
timeit.timeit(test,setup,number=1000)
# output: 1.3041282850317657
timeit.timeit(test,setup,number=1000)
# output: 1.6257964670658112
timeit.timeit(test,setup,number=1000)
# output: 1.9830634299432859

=> getting slower and slower...

Note that matplotlib 2.0.0, 2.0.1, 2.1.0, 2.1.2, 2.2.2, or 3.0.0 has the same slowdown behavior. If I set svg as backend, the result is the same as agg (I confirmed backend="svg" only for matplotlib 2.0.0 on the Ubuntu server).

This behavior is also confirmed on a Windows distribution: WinPython-64bit-3.4.4.6Qt5 with matplotlib 2.0.0 and default backend TkAgg, but not for WinPython-64bit-3.4.4.5Qt5 with matplotlib version 1.5.3 and TkAgg, though the situation is much more complex. In addition, WinPython-64bit-3.5.1.1 and matplotlib 2.1.0 (packaged by Christoph Gohlke) causes the same slowdown behavior.

Another windows distribution anaconda 3 version 4.4.0(64bit) also has a similar behavior. For the anaconda 3 environment, all the packages are default of the 4.4.0 distribution (python 3.6 and matplotlib 2.0.2 with backend qt5agg), and it runs on windows7-x64.

If I close the figure by pyplot.close(), the slowed plot() is returned to the initial state.

Expected outcome

The expected outcome is "no slowdown for the plot function of matplotlib 3" as the matplotlib version 1.5.3.

Matplotlib version

  • Operating system: Ubuntu 1604 (CLI only)
  • Matplotlib version: matplotlib 2.2.2
  • Matplotlib backend (print(matplotlib.get_backend())): agg
  • Python version: 3.5.2
  • Jupyter version (if applicable):
  • Other libraries:

Other note

I have used a script to write huge maps with overlayed many lines drawn by plot(). When I'm with matplotlib 1.5.3, it takes several minutes to output a bunch of png files, but no fatal problem is there. However, with 2.0.0-3.0.0 it takes several hours or more, and I cannot use the script at all.

I believe this slowdown behavior can occur for most of the matplotlib environments. I think this is not an OS/system dependent bug/specification.

@jklymak
Copy link
Member

jklymak commented Oct 17, 2018

I agree your script slows down for me.

OTOH, initializing a figure in your setup doesn't exhibit this slow down. I'm not sure why this happens, but I'm not sure what your use case is.

In general, I'd suggest not trying to use the state-based interface for the best performance...

setup="""import matplotlib.pyplot as plt
a=[1,2]
fig = plt.figure()
"""
test="plt.plot(a, a, '-')"
import timeit
timeit.timeit(test,setup,number=1000)
timeit.timeit(test,setup,number=1000)
timeit.timeit(test,setup,number=1000)
timeit.timeit(test,setup,number=1000)
timeit.timeit(test,setup,number=1000)
timeit.timeit(test,setup,number=1000)

@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

OTOH, initializing a figure in your setup doesn't exhibit this slow down. I'm not sure why this happens, but I'm not sure what your use case is.

But I think that's the point, since:

If I close the figure by pyplot.close(), the slowed plot() is returned to the initial state.

On the other hand, drawing 4000 lines vs 1000 lines seems like it should be slower to me, but is it too much slower per line? I'm not sure, possibly, but I think a more interesting test is this:

>>> setup="""import matplotlib.pyplot as plt
... a=[1,2]
... plt.close('all')
... """
>>> 
>>> test="plt.plot(a,a,'-')"
>>> import timeit
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=1)
>>> timeit.timeit(test,setup,number=10)
>>> timeit.timeit(test,setup,number=100)
>>> timeit.timeit(test,setup,number=1000)
>>> timeit.timeit(test,setup,number=10000)
>>> timeit.timeit(test,setup,number=100000)
>>> timeit.timeit(test,setup,number=200000)

which does not seem to scale so well in recent versions:
figure_1-2
Extrapolating says it should take 400s for count=100000 on 3.0.0, but I'm still waiting ~60 minutes later...

@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

Oh, actually, 100000 count finished, so (this is loglog):
figure_1-3
Looking at time per line (just time / number) (this is only semilogy because the count shouldn't matter):
figure_1-4
Ignoring 1 and 10 which are pretty noisy, 10000 lines is an order of magnitude slower, but at 100000 lines, it's two orders of magnitude slower per line. I don't know what's going on there.

@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

More straightforward test, I think?

import timeit

setup = """
import matplotlib.pyplot as plt
plt.close('all')
a = [1, 2]
"""
test = "plt.plot(a,a,'-')"

# Prime stuff
timeit.timeit(test, setup, number=10)

t1 = timeit.timeit(test, setup, number=1000)
t2 = timeit.timeit(test, setup, number=10000)
print(t1, t2, t2 / t1)

with results:

version	time1000		time10000		ratio
1.5.3	1.665212828986114	8.014447881025262	4.812867005057222
2.0.0	2.089658019016497	44.532892381976126	21.311091086059935
2.0.1	2.085433025000384	44.81260865801596	21.488395033931962
2.0.2	2.05447192498832	42.90871796300053	20.88552169591924
2.1.0	2.251952402002644	40.38666930099134	17.934068795182256
2.1.1	1.9482705270056613	38.53039746900322	19.776718343227937
2.1.2	2.0048703539941926	40.01708639998105	19.959937220008875
2.2.0	2.104869660019176	40.214108515006956	19.105272539602566
2.2.1	1.962975592003204	39.36644780100323	20.05447645980663
2.2.2	1.9735110830224585	39.55972639701213	20.04535304480068
2.2.3	1.9041270589805208	38.6958993919834	20.322120422311198
3.0.0	2.072705873986706	39.154758671997115	18.890648771446596

The base time went up slightly, but the main point is that the ratio is much higher now for some reason.

@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

Ran an automated bisect with ratio=13 as good/bad threshold and it points to 9617dfe as the first bad commit

commit 9617dfef1f377760645b7986d3081cca34aa0805
Author: Thomas A Caswell <tcaswell@gmail.com>
Date:   Tue Dec 8 13:09:10 2015 -0500

    Merge pull request #5583 from mdboom/padding
    
    API: Use data limits plus a little padding by default

:040000 040000 b905ec739efee2f8b0c4ebb14b46760497c30e6d a7b4c85793d0d175bee8582c91c7c625dc4d909e M	lib
:100644 100644 b4e602b846c5c1b360fede4fc3c2b16fd095b57d 604b1036d911e9522d67d35489dad897b94f4a85 M	matplotlibrc.template

which I guess might make sense as automated margins would require iterating through all artists.

@anntzer
Copy link
Contributor

anntzer commented Oct 17, 2018

Thanks for figuring this out.
This points once again to #7413, namely @efiring's first point:

It looks very inefficient: every plotting method in _axes adds an artist to the axes and then calls autoscale_view, occasionally with arguments. autoscale_view then does a complete autoscaling operation, going through all of the artists that have been added up to that point. Logically, it seems like the autoscaling should be done only before a draw operation, not every time an artist is added.

We're hitting quadratic behavior here :/

@dstansby
Copy link
Member

If anyone wants a fun project, this looks like a good excuse to dig up #2188 / https://github.com/airspeed-velocity/asv - there are some older results at https://matplotlib.org/mpl-bench/

@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

I'm still waiting for them to accept the PR to enable it on NumPy's bench system...

@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

A pretty generous speed up can be had by disabling autoscale and sticky edges before plotting, and then re-enabling them after:

import matplotlib.pyplot as plt

a = [1, 2]

fig, ax = plt.subplots()
for _ in range(10000):
    ax.plot(a, a, '-')
$ time python ../mpl-tests/issue12542a.py 
real	0m39.860s
user	0m38.035s
sys	0m0.186s

vs.

import matplotlib.pyplot as plt

a = [1, 2]

fig, ax = plt.subplots()
ax.autoscale(False)
ax.use_sticky_edges = False
for _ in range(10000):
    ax.plot(a, a, '-')
ax.use_sticky_edges = True
ax.autoscale(True)
$ time python ../mpl-tests/issue12542a.py 
real	0m7.476s
user	0m5.423s
sys	0m0.168s

@anntzer
Copy link
Contributor

anntzer commented Oct 17, 2018

See #12546 for a small improvement.

QuLogic added a commit to QuLogic/matplotlib that referenced this issue Oct 17, 2018
If there's not margin to be added, we don't need sticky edges, but if
autoscaling is off, we _also_ don't need the sticky edges. This saves a
lot of time when plotting many artists, like in matplotlib#12542.
QuLogic added a commit to QuLogic/matplotlib that referenced this issue Oct 17, 2018
If there's no margin to be added, we don't need sticky edges, but if
autoscaling is off, we _also_ don't need the sticky edges. This saves a
lot of time when plotting many artists, like in matplotlib#12542.
@QuLogic
Copy link
Member

QuLogic commented Oct 17, 2018

#12547 is a related change insomuch as it helps if you've already disable autoscale, but it's orthogonal to @anntzer's change.

@jklymak
Copy link
Member

jklymak commented Oct 17, 2018

Ooops, I see what the difference is now in the tests. Sorry for the noise.

I think a todo here, asside from makig the autolim algorithm faster, is to have a Performance Tweaks page where we list potential performance blocks that will slow things down for folks who want to have thousands of artists, or a very responsive GUI. Off the top of my head:

  • autolimits
  • "best" legend location
  • the layout managers

No doubt there are more.

@ImportanceOfBeingErnest
Copy link
Member

Adding to the list (especially with respect to the actual topic here)

  • Using collections instead of individual artists. I.e. a LineCollection instead of many plots, a PathCollection or PolyCollection or even Path.make_compound_path_from_polys instead of a bar with many bars (like here), etc.

@QuLogic
Copy link
Member

QuLogic commented Oct 18, 2018

OK, interestingly, though we definitely would want to avoid autoscale being called too much, looking at a profiler, it's actually accumulating sticky edges that takes the most time (even with @anntzer's optimization there):

Total time: 57.1916 s
File: .../matplotlib/lib/matplotlib/axes/_base.py
Function: autoscale_view at line 2382

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  2382                                               @profile
  2383                                               def autoscale_view(self, tight=None, scalex=True, scaley=True):
  2384                                                   """
  2385                                                   Autoscale the view limits using the data limits.
  2386                                           
  2387                                                   You can selectively autoscale only a single axis, e.g., the xaxis by
  2388                                                   setting *scaley* to *False*.  The autoscaling preserves any
  2389                                                   axis direction reversal that has already been done.
  2390                                           
  2391                                                   If *tight* is *False*, the axis major locator will be used
  2392                                                   to expand the view limits if rcParams['axes.autolimit_mode']
  2393                                                   is 'round_numbers'.  Note that any margins that are in effect
  2394                                                   will be applied first, regardless of whether *tight* is
  2395                                                   *True* or *False*.  Specifying *tight* as *True* or *False*
  2396                                                   saves the setting as a private attribute of the Axes; specifying
  2397                                                   it as *None* (the default) applies the previously saved value.
  2398                                           
  2399                                                   The data limits are not updated automatically when artist data are
  2400                                                   changed after the artist has been added to an Axes instance.  In that
  2401                                                   case, use :meth:`matplotlib.axes.Axes.relim` prior to calling
  2402                                                   autoscale_view.
  2403                                                   """
  2404     10000      24447.0      2.4      0.0          if tight is not None:
  2405                                                       self._tight = bool(tight)
  2406                                           
  2407     10000      29351.0      2.9      0.1          if self.use_sticky_edges and (
  2408     10000      24853.0      2.5      0.0                  (self._xmargin and scalex and self._autoscaleXon) or
  2409                                                           (self._ymargin and scaley and self._autoscaleYon)):
  2410     10000   27187493.0   2718.7     47.5              stickies = [artist.sticky_edges for artist in self.get_children()]
  2411     10000    8970882.0    897.1     15.7              x_stickies = np.array([x for sticky in stickies for x in sticky.x])
  2412     10000    9718730.0    971.9     17.0              y_stickies = np.array([y for sticky in stickies for y in sticky.y])
  2413     10000      46397.0      4.6      0.1              if self.get_xscale().lower() == 'log':
  2414                                                           x_stickies = x_stickies[x_stickies > 0]
  2415     10000      30285.0      3.0      0.1              if self.get_yscale().lower() == 'log':
  2416                                                           y_stickies = y_stickies[y_stickies > 0]
  2417                                                   else:  # Small optimization.
  2418                                                       x_stickies, y_stickies = [], []
  2419                                           
  2420     10000     257034.0     25.7      0.4          @profile
  2421                                                   def handle_single_axis(scale, autoscaleon, shared_axes, interval,
  2422                                                                          minpos, axis, margin, stickies, set_bound):
  2423                                           
  2424                                                       if not (scale and autoscaleon):
  2425                                                           return  # nothing to do...
  2426                                           
  2427                                                       shared = shared_axes.get_siblings(self)
  2428                                                       dl = [ax.dataLim for ax in shared]
  2429                                                       # ignore non-finite data limits if good limits exist
  2430                                                       finite_dl = [d for d in dl if np.isfinite(d).all()]
  2431                                                       if len(finite_dl):
  2432                                                           # if finite limits exist for atleast one axis (and the
  2433                                                           # other is infinite), restore the finite limits
  2434                                                           x_finite = [d for d in dl
  2435                                                                       if (np.isfinite(d.intervalx).all() and
  2436                                                                           (d not in finite_dl))]
  2437                                                           y_finite = [d for d in dl
  2438                                                                       if (np.isfinite(d.intervaly).all() and
  2439                                                                           (d not in finite_dl))]
  2440                                           
  2441                                                           dl = finite_dl
  2442                                                           dl.extend(x_finite)
  2443                                                           dl.extend(y_finite)
  2444                                           
  2445                                                       bb = mtransforms.BboxBase.union(dl)
  2446                                                       x0, x1 = getattr(bb, interval)
  2447                                                       locator = axis.get_major_locator()
  2448                                                       try:
  2449                                                           # e.g., DateLocator has its own nonsingular()
  2450                                                           x0, x1 = locator.nonsingular(x0, x1)
  2451                                                       except AttributeError:
  2452                                                           # Default nonsingular for, e.g., MaxNLocator
  2453                                                           x0, x1 = mtransforms.nonsingular(
  2454                                                               x0, x1, increasing=False, expander=0.05)
  2455                                           
  2456                                                       # Add the margin in figure space and then transform back, to handle
  2457                                                       # non-linear scales.
  2458                                                       minpos = getattr(bb, minpos)
  2459                                                       transform = axis.get_transform()
  2460                                                       inverse_trans = transform.inverted()
  2461                                                       # We cannot use exact equality due to floating point issues e.g.
  2462                                                       # with streamplot.
  2463                                                       do_lower_margin = not np.any(np.isclose(x0, stickies))
  2464                                                       do_upper_margin = not np.any(np.isclose(x1, stickies))
  2465                                                       x0, x1 = axis._scale.limit_range_for_scale(x0, x1, minpos)
  2466                                                       x0t, x1t = transform.transform([x0, x1])
  2467                                           
  2468                                                       if (np.isfinite(x1t) and np.isfinite(x0t)):
  2469                                                           delta = (x1t - x0t) * margin
  2470                                                       else:
  2471                                                           # If at least one bound isn't finite, set margin to zero
  2472                                                           delta = 0
  2473                                           
  2474                                                       if do_lower_margin:
  2475                                                           x0t -= delta
  2476                                                       if do_upper_margin:
  2477                                                           x1t += delta
  2478                                                       x0, x1 = inverse_trans.transform([x0t, x1t])
  2479                                           
  2480                                                       if not self._tight:
  2481                                                           x0, x1 = locator.view_limits(x0, x1)
  2482                                                       set_bound(x0, x1)
  2483                                                       # End of definition of internal function 'handle_single_axis'.
  2484                                           
  2485     10000      19034.0      1.9      0.0          handle_single_axis(
  2486     10000      24963.0      2.5      0.0              scalex, self._autoscaleXon, self._shared_x_axes, 'intervalx',
  2487     10000    5609615.0    561.0      9.8              'minposx', self.xaxis, self._xmargin, x_stickies, self.set_xbound)
  2488     10000      20417.0      2.0      0.0          handle_single_axis(
  2489     10000      20418.0      2.0      0.0              scaley, self._autoscaleYon, self._shared_y_axes, 'intervaly',
  2490     10000    5207680.0    520.8      9.1              'minposy', self.yaxis, self._ymargin, y_stickies, self.set_ybound)

Total time: 9.55789 s
File: .../matplotlib/lib/matplotlib/axes/_base.py
Function: handle_single_axis at line 2420

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  2420                                                   @profile
  2421                                                   def handle_single_axis(scale, autoscaleon, shared_axes, interval,
  2422                                                                          minpos, axis, margin, stickies, set_bound):
  2423                                           
  2424     20000      25491.0      1.3      0.3              if not (scale and autoscaleon):
  2425                                                           return  # nothing to do...
  2426                                           
  2427     20000     131620.0      6.6      1.4              shared = shared_axes.get_siblings(self)
  2428     20000      37372.0      1.9      0.4              dl = [ax.dataLim for ax in shared]
  2429                                                       # ignore non-finite data limits if good limits exist
  2430     20000     361819.0     18.1      3.8              finite_dl = [d for d in dl if np.isfinite(d).all()]
  2431     20000      32171.0      1.6      0.3              if len(finite_dl):
  2432                                                           # if finite limits exist for atleast one axis (and the
  2433                                                           # other is infinite), restore the finite limits
  2434     20000     184566.0      9.2      1.9                  x_finite = [d for d in dl
  2435                                                                       if (np.isfinite(d.intervalx).all() and
  2436                                                                           (d not in finite_dl))]
  2437     20000     129965.0      6.5      1.4                  y_finite = [d for d in dl
  2438                                                                       if (np.isfinite(d.intervaly).all() and
  2439                                                                           (d not in finite_dl))]
  2440                                           
  2441     20000      25877.0      1.3      0.3                  dl = finite_dl
  2442     20000      27806.0      1.4      0.3                  dl.extend(x_finite)
  2443     20000      23528.0      1.2      0.2                  dl.extend(y_finite)
  2444                                           
  2445     20000    1819300.0     91.0     19.0              bb = mtransforms.BboxBase.union(dl)
  2446     20000     115457.0      5.8      1.2              x0, x1 = getattr(bb, interval)
  2447     20000      47695.0      2.4      0.5              locator = axis.get_major_locator()
  2448     20000      22764.0      1.1      0.2              try:
  2449                                                           # e.g., DateLocator has its own nonsingular()
  2450     20000      43310.0      2.2      0.5                  x0, x1 = locator.nonsingular(x0, x1)
  2451     20000      27332.0      1.4      0.3              except AttributeError:
  2452                                                           # Default nonsingular for, e.g., MaxNLocator
  2453     20000      26835.0      1.3      0.3                  x0, x1 = mtransforms.nonsingular(
  2454     20000     294789.0     14.7      3.1                      x0, x1, increasing=False, expander=0.05)
  2455                                           
  2456                                                       # Add the margin in figure space and then transform back, to handle
  2457                                                       # non-linear scales.
  2458     20000      47082.0      2.4      0.5              minpos = getattr(bb, minpos)
  2459     20000     113594.0      5.7      1.2              transform = axis.get_transform()
  2460     20000      33573.0      1.7      0.4              inverse_trans = transform.inverted()
  2461                                                       # We cannot use exact equality due to floating point issues e.g.
  2462                                                       # with streamplot.
  2463     20000    1383600.0     69.2     14.5              do_lower_margin = not np.any(np.isclose(x0, stickies))
  2464     20000    1092805.0     54.6     11.4              do_upper_margin = not np.any(np.isclose(x1, stickies))
  2465     20000      50382.0      2.5      0.5              x0, x1 = axis._scale.limit_range_for_scale(x0, x1, minpos)
  2466     20000     159674.0      8.0      1.7              x0t, x1t = transform.transform([x0, x1])
  2467                                           
  2468     20000      99877.0      5.0      1.0              if (np.isfinite(x1t) and np.isfinite(x0t)):
  2469     20000      35382.0      1.8      0.4                  delta = (x1t - x0t) * margin
  2470                                                       else:
  2471                                                           # If at least one bound isn't finite, set margin to zero
  2472                                                           delta = 0
  2473                                           
  2474     20000      23609.0      1.2      0.2              if do_lower_margin:
  2475     20000      27287.0      1.4      0.3                  x0t -= delta
  2476     20000      23276.0      1.2      0.2              if do_upper_margin:
  2477     20000      27478.0      1.4      0.3                  x1t += delta
  2478     20000     111685.0      5.6      1.2              x0, x1 = inverse_trans.transform([x0t, x1t])
  2479                                           
  2480     20000      31767.0      1.6      0.3              if not self._tight:
  2481     20000     343003.0     17.2      3.6                  x0, x1 = locator.view_limits(x0, x1)
  2482     20000    2576115.0    128.8     27.0              set_bound(x0, x1)

@anntzer
Copy link
Contributor

anntzer commented Oct 18, 2018

One can squeeze out another ~30%(!) or so by replacing the property access (artist.sticky_edges, sticky.x, sticky.y) by direct attribute/tuple access (artist._sticky_edges, sticky[0], sticky[1] (sticky is a namedtuple)), which I guess is a reasonable patch (with a comment explaining the performance implications) but really just a band-aid over a problematic design.

@materia9
Copy link
Author

Thank you everyone. I now realize that the root cause of the problem at least for my case. Now I'm trying to use the options: autoscale(False) and use_sticky_edges = False. I'm a lazy programmer and thus I found this behavior (maybe a good programmer can solve the speed limitation completely and never realizes the problem). I think a performance tweak page will be a very good start point even for me.

@jklymak
Copy link
Member

jklymak commented Oct 18, 2018

So is ti possible to delay autoscale_view to draw time? Seems a straightforward solution.

If that is not possible, could we accumulate x_stickies and y_stickies in add_artist and remove them in artist.remove? Unless I'm misunderstanding, the problem is that making these lists from scratch every time a new artist is added is inefficient.

@WeatherGod
Copy link
Member

WeatherGod commented Oct 18, 2018 via email

@tacaswell
Copy link
Member

If we cache the sticky-ness, we need to track if it is updated on the artists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants