perf(animation): scalar-float fast path skipping variant visits; closes #342

The animation hot path re-dispatched the AnimationValue std::variant twice
per frame -- once in interpolate() and once in applyValue() -- via
std::visit. Profiling showed this __do_visit machinery was ~473M Ir (13.4%
of the benchmark), by far the largest reducible animation cost.

(The originally-hypothesized culprits were disproven by the profiler: the
setProperty strcmp cascade is cold -- std::string == const char* short-
circuits on length, ~0.15%/branch -- and an attempt to cut update()'s
weak_ptr triple-lock was a wash, since locking an empty weak_ptr touches no
control block or atomics.)

Scalar-float animations (x, y, w, h, opacity, rotation, color channels...)
are the overwhelmingly common case. A single flag set at construction
(isSimpleFloatAnim) lets update() interpolate the float directly and call
setProperty(float), bypassing both std::visit dispatches. startValue is
always float-holding for a float targetValue, so the fast path is safe.

Callgrind A/B (tests/benchmarks/sprint_perf_baseline.py):
  Animation::update inclusive Ir: 1,479,186,800 -> 1,207,377,600  (-18%)
  variant __do_visit self Ir (anim): 473.2M -> 98.8M  (-79%)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-10 22:59:28 -04:00
commit 5bbffe283a
3 changed files with 101 additions and 9 deletions

View file

@ -48,6 +48,12 @@ Animation::Animation(const std::string& targetProperty,
if (pythonCallback) {
Py_INCREF(pythonCallback);
}
// #342 - Scalar-float animations (the overwhelmingly common case: x, y, w, h,
// opacity, rotation, color channels...) can interpolate and apply without the
// two per-frame std::variant visits. startValue is always float-holding for a
// float targetValue (start() only writes float there), so the fast path is safe.
isSimpleFloatAnim = std::holds_alternative<float>(targetValue);
}
Animation::~Animation() {
@ -311,16 +317,27 @@ bool Animation::update(float deltaTime) {
float t = duration > 0 ? elapsed / duration : 1.0f;
float easedT = easingFunc(t);
// Get interpolated value
AnimationValue currentValue = interpolate(easedT);
// Apply to whichever target is valid
if (target) {
applyValue(target.get(), currentValue);
} else if (entity) {
applyValue(entity.get(), currentValue);
} else if (entity3d) {
applyValue(entity3d.get(), currentValue);
if (isSimpleFloatAnim) {
// #342 - fast path: direct float interpolate + setProperty, skipping the
// two per-frame std::variant visits (interpolate() and applyValue()).
float v = interpolateFloat(easedT);
if (target) {
target->setProperty(targetProperty, v);
} else if (entity) {
entity->setProperty(targetProperty, v);
} else if (entity3d) {
entity3d->setProperty(targetProperty, v);
}
} else {
AnimationValue currentValue = interpolate(easedT);
if (target) {
applyValue(target.get(), currentValue);
} else if (entity) {
applyValue(entity.get(), currentValue);
} else if (entity3d) {
applyValue(entity3d.get(), currentValue);
}
}
// Trigger callback when animation completes
@ -338,6 +355,16 @@ AnimationValue Animation::getCurrentValue() const {
return interpolate(easedT);
}
float Animation::interpolateFloat(float t) const {
// #342 - mirrors the float branch of interpolate() without the std::visit.
const float* start = std::get_if<float>(&startValue);
const float* tgt = std::get_if<float>(&targetValue);
if (!tgt) return start ? *start : 0.0f; // unreachable when isSimpleFloatAnim
if (!start) return *tgt; // matches interpolate()'s type-mismatch return
if (delta) return *start + *tgt * t;
return *start + (*tgt - *start) * t;
}
AnimationValue Animation::interpolate(float t) const {
// Visit the variant to perform type-specific interpolation
return std::visit([this, t](const auto& target) -> AnimationValue {

View file

@ -115,6 +115,12 @@ private:
std::weak_ptr<UIDrawable> targetWeak;
std::weak_ptr<UIEntity> entityTargetWeak;
std::weak_ptr<mcrf::Entity3D> entity3dTargetWeak;
// #342 - Fast path: a scalar-float animation (targetValue holds float) can
// interpolate and apply directly, skipping the two per-frame std::variant
// std::visit dispatches (interpolate() + applyValue()), which profiling
// showed to be ~13% of the animation hot path. Set once at construction.
bool isSimpleFloatAnim = false;
// Callback support
PyObject* pythonCallback = nullptr; // Python callback function (we own a reference)
@ -126,6 +132,10 @@ private:
// Helper to interpolate between values
AnimationValue interpolate(float t) const;
// #342 - Direct scalar-float interpolation, no std::variant visit. Returns
// the eased value; only valid when isSimpleFloatAnim is true.
float interpolateFloat(float easedT) const;
// Helper to apply value to target
void applyValue(UIDrawable* target, const AnimationValue& value);

View file

@ -0,0 +1,55 @@
"""Regression test for #342 - Animation scalar-float fast path.
The animation hot path was optimized to bypass two per-frame std::variant
std::visit dispatches (interpolate() + applyValue()) for scalar-float
animations (x, y, w, h, opacity, rotation, color channels, ...). This test
verifies that the fast path produces the SAME interpolated values as the
general path -- i.e. the optimization is behavior-preserving.
Direct-execution style with headless mcrfpy.step() to advance animations.
"""
import mcrfpy
import sys
def approx(a, b, tol=0.5):
return abs(a - b) < tol
def main():
scene = mcrfpy.Scene("t342")
mcrfpy.current_scene = scene
f = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
f.fill_color = mcrfpy.Color(10, 20, 30, 0)
scene.children.append(f)
# Scalar-float property animations (these hit the #342 fast path)
f.animate("x", 100.0, 10.0, mcrfpy.Easing.LINEAR) # 0 -> 100
f.animate("opacity", 0.0, 10.0, mcrfpy.Easing.LINEAR) # 1.0 -> 0.0
f.animate("fill_color.a", 200.0, 10.0, mcrfpy.Easing.LINEAR) # 0 -> 200, deep name
# Halfway (linear): each property at 50%
mcrfpy.step(5.0)
assert approx(f.x, 50.0), f"x halfway: {f.x}"
assert approx(f.opacity, 0.5, 0.02), f"opacity halfway: {f.opacity}"
assert approx(f.fill_color.a, 100.0, 1.5), f"fill_color.a halfway: {f.fill_color.a}"
# Complete
mcrfpy.step(5.0)
assert approx(f.x, 100.0), f"x end: {f.x}"
assert approx(f.opacity, 0.0, 0.02), f"opacity end: {f.opacity}"
assert approx(f.fill_color.a, 200.0, 1.5), f"fill_color.a end: {f.fill_color.a}"
# A non-linear easing still lands exactly on the target (fast path end value)
f2 = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
scene.children.append(f2)
f2.animate("y", 42.0, 4.0, mcrfpy.Easing.EASE_IN_OUT)
mcrfpy.step(4.0)
assert approx(f2.y, 42.0), f"eased y end: {f2.y}"
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()