Skip to content

Examine hot-loop of geoms for performance gains #42

Description

@thomasp85

From exploration of the point geom:

I benchmarked PointGeom::draw directly (200k rows, release, best of 7, into a null SceneBuilder so nothing is rasteriser cost; the scene.fill call itself is ~3 ns/row):

change (cumulative) | continuous x/y | discrete x, 8 levels -- | -- | -- baseline | 106 ns/row | 132 ns/row &Value + skip band lookup when size_band == 0 | 102 | 121 hoist the shape name + registry lookup | 61.7 | 82.4 hoist theme fill/stroke resolve | 59.3 | 80.3 #[inline(always)] on the resolve_* helpers | 41.8 | 53.8

All 1641 lib tests passed with the whole stack applied.

The three real findings, in order of size:

  1. resolve_str_channel_or allocates a String per row, then does a HashMap lookup per row (point.rs:290, resolve.rs:248). With "shape" unset or Constant — the overwhelmingly common case — the name is row-invariant, so this is pure waste: 40 ns/row, 38% of the loop. Resolve once before the loop when the channel isn't Data/RawData, keeping the per-row path only for data-driven shapes.

  2. The resolve_* helpers don't inline, so unset channels still cost a real call each. resolve_value returns Option<Value> out-of-line, ~12 times per row; sample attributed 36% of the loop to resolve_value + resolve_number_channel alone. Plain #[inline] changed nothing (LLVM rejects them on size); #[inline(always)] folds the channel?-is-None cases away entirely — the no-scale, no-draw floor drops from 39.4 to 13.0 ns/row. ~18 ns/row, and it benefits every geom, not just points. The tradeoff is code size across ~15 geoms.

  3. Theme fallbacks re-resolve per row. resolve_color_channel_or_theme calls ThemeColor::resolve(palette) on every row whose colour channel is unbound; theme_fill/theme_stroke are loop-invariant. Small (~2 ns/row) but free to fix.

Two more, not lifted in the measurement:

  • Scale::map re-derives its own constants every call. continuous_map does three transform.forward calls per value, two of which are on the domain endpoints — identical for every row. The two position maps cost 17 ns/row combined; a per-draw prepared mapper (endpoints transformed once, span reciprocal precomputed) should take most of that. That's a scales API change, not a geom one.
  • discrete_map is O(|domain|) per row  domain.iter().position(|d| d.key_eq(input)) at scale_type.rs:375. At 8 levels it's part of the 12 ns/row gap between the two columns above; at a few hundred categories it dominates. A cached value→index map on the scale would fix it. I didn't measure the scaling with domain size.

I saved the full experimental patch at scratchpad/hotloop-experiment.diff — say the word and I'll apply items 1–3 properly (including converting resolve_position to &Value across all call sites rather than the temporary duplicate helper I used for measurement).

I benchmarked PointGeom::draw directly (200k rows, release, best of 7, into a null SceneBuilder so nothing is rasteriser cost; the scene.fill call itself is ~3 ns/row):

change (cumulative) continuous x/y discrete x, 8 levels
baseline 106 ns/row 132 ns/row
&Value + skip band lookup when size_band == 0 102 121
hoist the shape name + registry lookup 61.7 82.4
hoist theme fill/stroke resolve 59.3 80.3
#[inline(always)] on the resolve_* helpers 41.8 53.8
All 1641 lib tests passed with the whole stack applied.

The three real findings, in order of size:

resolve_str_channel_or allocates a String per row, then does a HashMap lookup per row (point.rs:290, resolve.rs:248). With "shape" unset or Constant — the overwhelmingly common case — the name is row-invariant, so this is pure waste: 40 ns/row, 38% of the loop. Resolve once before the loop when the channel isn't Data/RawData, keeping the per-row path only for data-driven shapes.

The resolve_* helpers don't inline, so unset channels still cost a real call each. resolve_value returns Option out-of-line, ~12 times per row; sample attributed 36% of the loop to resolve_value + resolve_number_channel alone. Plain #[inline] changed nothing (LLVM rejects them on size); #[inline(always)] folds the channel?-is-None cases away entirely — the no-scale, no-draw floor drops from 39.4 to 13.0 ns/row. ~18 ns/row, and it benefits every geom, not just points. The tradeoff is code size across ~15 geoms.

Theme fallbacks re-resolve per row. resolve_color_channel_or_theme calls ThemeColor::resolve(palette) on every row whose colour channel is unbound; theme_fill/theme_stroke are loop-invariant. Small (~2 ns/row) but free to fix.

Two more, not lifted in the measurement:

Scale::map re-derives its own constants every call. continuous_map does three transform.forward calls per value, two of which are on the domain endpoints — identical for every row. The two position maps cost 17 ns/row combined; a per-draw prepared mapper (endpoints transformed once, span reciprocal precomputed) should take most of that. That's a scales API change, not a geom one.
discrete_map is O(|domain|) per row — domain.iter().position(|d| d.key_eq(input)) at scale_type.rs:375. At 8 levels it's part of the 12 ns/row gap between the two columns above; at a few hundred categories it dominates. A cached value→index map on the scale would fix it. I didn't measure the scaling with domain size.
I saved the full experimental patch at scratchpad/hotloop-experiment.diff — say the word and I'll apply items 1–3 properly (including converting resolve_position to &Value across all call sites rather than the temporary duplicate helper I used for measurement).

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions