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:
parent
0e4b36f42a
commit
5bbffe283a
3 changed files with 101 additions and 9 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
55
tests/regression/issue_342_animation_float_fastpath_test.py
Normal file
55
tests/regression/issue_342_animation_float_fastpath_test.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue