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:
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<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.
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).
From exploration of the point geom:
I benchmarked
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.8PointGeom::drawdirectly (200k rows, release, best of 7, into a nullSceneBuilderso nothing is rasteriser cost; thescene.fillcall itself is ~3 ns/row):All 1641 lib tests passed with the whole stack applied.
The three real findings, in order of size:
resolve_str_channel_orallocates aStringper row, then does aHashMaplookup per row (point.rs:290, resolve.rs:248). With"shape"unset orConstant— 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'tData/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_valuereturnsOption<Value>out-of-line, ~12 times per row;sampleattributed 36% of the loop toresolve_value+resolve_number_channelalone. Plain#[inline]changed nothing (LLVM rejects them on size);#[inline(always)]folds thechannel?-is-Nonecases 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_themecallsThemeColor::resolve(palette)on every row whose colour channel is unbound;theme_fill/theme_strokeare loop-invariant. Small (~2 ns/row) but free to fix.Two more, not lifted in the measurement:
Scale::mapre-derives its own constants every call. continuous_map does threetransform.forwardcalls 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 ascalesAPI change, not a geom one.discrete_mapis 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
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):scratchpad/hotloop-experiment.diff— say the word and I'll apply items 1–3 properly (including convertingresolve_positionto&Valueacross all call sites rather than the temporary duplicate helper I used for measurement).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).