posit-dev / posit-dev/hephaestus

Examine hot-loop of geoms for performance gains

Open
#42 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
20
Forks
1
Avg merge
9h 18m
Merged PRs (30d)
4

Description

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).

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at PointGeom::draw in src/plot/geom/point.rs, then read the resolve helpers in src/plot/geom/resolve.rs and Scale::map in src/scales/scale_type.rs. Compare the loop-invariant and per-row paths described in the benchmark, run the lib test suite, and confirm the selected changes preserve behavior while improving the hot-loop measurements.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
computer-graphics, performance
Issue type
Refactor
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.