Skip to content

Draw blocks as one collection instead of one artist per block #77

Description

@gyli

_draw_blocks creates one matplotlib.patches.Rectangle per block and adds it with ax.add_artist. matplotlib then transforms and draws each one individually, so both building and drawing scale linearly with an expensive per-block constant.

For a normal chart of tens or hundreds of blocks this is invisible and not worth changing. It matters for the thousand-plus-block charts that #2 was about, and it is the reason MAX_BLOCKS exists as a backstop rather than as a considered limit.

Measured

Three categories, one chart, timed as build plus canvas.draw(), best of five:

blocks current one PatchCollection one PolyCollection from a vertex array
1,000 0.070s 0.035s 0.024s
10,000 0.701s 0.290s 0.030s
50,000 3.711s 1.496s 0.129s

Split into build and draw at 50,000 blocks:

build draw
current 1.746s 1.965s
PatchCollection 1.429s 0.067s
PolyCollection 0.069s 0.060s

A PatchCollection fixes drawing but not building, because constructing 50,000 Rectangle objects in Python is what dominates once drawing is cheap. Building the vertex array directly, with no per-block Python object at all, is what removes the rest. 29x faster overall at 50,000 blocks.

Where it starts to pay

blocks current PolyCollection speedup
50 0.0051s 0.0078s 0.7x
100 0.0078s 0.0080s 1.0x
500 0.0300s 0.0095s 3.2x
1,000 0.0589s 0.0096s 6.1x
5,000 0.3541s 0.0190s 18.6x

Below about a hundred blocks the collection is marginally slower, since the array setup costs more than a handful of rectangles.

Proposal

Draw rectangle blocks as a single PolyCollection built from an (n, 4, 2) vertex array, above a block-count threshold. Keep the current per-Rectangle path below it.

A threshold rather than a straight swap, for one reason: a collection leaves ax.patches empty. Nothing in the README or the documentation promises otherwise, but drawing the blocks as individual patches is observable, and someone may well be reaching into ax.patches to tweak a chart after the fact. That is plausible for a chart of 50 blocks and implausible for one of 50,000, which is exactly where the threshold falls anyway. MAX_BLOCKS already sets the precedent for a module-level tunable.

The three awkward cases all work

Checked against a prototype rather than assumed:

  • Per-block colours map to the collection's facecolors array.
  • Transparent new-line padding works: alpha 0 in both facecolors and edgecolors, which preserves the fix from Don't outline the blank cells that new-line padding leaves behind #62 where padding must not pick up a visible border.
  • Fractional blocks work: rounding_rule="float" emits partial and split cells, and a PolyCollection takes arbitrary quads, so a half-width block is just different vertices rather than a special case.
  • block_edge_color / block_edge_width become the edgecolors and linewidths arrays.

Out of scope

icons and characters draw Text, not patches, so they gain nothing here. They are the slower case, since each glyph is laid out individually, and would need a different approach entirely.

Safety net

The rendered output must not change at all below the threshold, and must not change perceptibly above it. tests/test_images.py --mpl covers this directly. The geometry fingerprint used when _make_single_waffle was split up applies too: render a few hundred parameter combinations and compare every block's position, size, face colour, edge colour and line width before and after.

The existing suite reads ax.patches in 33 places across 7 files, so those tests need a helper that reads blocks from whichever representation is in use. That is mechanical, but it is the bulk of the diff.

Worth doing?

Yes, but not urgently, and not as part of a feature release. It changes how every chart is rendered for a benefit most users will never see. The payoff is that charts in the thousands of blocks stop being slow, #2 gets a better answer than "use a vector format", and MAX_BLOCKS could be raised on evidence rather than as a guess.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions