Surprise: AnimationCurve.Evaluate is faster than Mathf.Lerp! But why?

I thought AnimationCurves are kind of nice and intuitive to use, especially for artists but also if you want to be more flexible. But I assumed that they would actually incur a little performance penalty for the flexibility.

Assumptions are never a good idea, so I started doing some little testing and found out something that really surprised me: While AnimationCurve takes 10-30 times as long as Mathf.Lerp on the first call (but we’re talking 0.1ms vs. 0.01ms, so it’s not like a big thing in absolute numbers), when you go over several loops, it actually is a little faster than Mathf.Lerp - even when you have a somewhat complex curve.

Does anyone know how this is achieved? Mathf.Lerp seems quite trivial - certainly much less interesting than evaluating a bezier function, so this is totally counter-intuitive. Either I have something wrong in my test-script, or there’s magic going on.

Here’s the test-script:

using UnityEngine;

public class SimpleCurve : MonoBehaviour {

    public AnimationCurve curve = new AnimationCurve();
    public int counter = 1000000;

    public float Evaluate(float t) {
        return curve.Evaluate(t);
    }

    public void Awake() {
        for (int i=0; i < 10; i++) {
            TestA();
            TestB();
        }
    }

    private void TestA() {
        System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
        sw.Start();
        float result = 0;
        for (float i=0; i < counter; i++) {
            result = curve.Evaluate(i / counter);
        }
        sw.Stop();
        Debug.LogFormat("Using curve for {3} iterations took:"
                        +" {0} ms, {1} ticks, result was: {2}", 
                        sw.ElapsedMilliseconds, sw.ElapsedTicks, 
                        result, counter);
    }

    private void TestB() {
        System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
        sw.Start();
        float result = 0;
        for (float i=0; i < counter; i++) {
            result = Mathf.Lerp(0.5F, 1.0F, i / counter);
        }
        sw.Stop();
        Debug.LogFormat("Using lerp for {3} iterations took:"
                        +" {0} ms, {1} ticks, result was: {2}", 
                        sw.ElapsedMilliseconds, sw.ElapsedTicks, 
                        result, counter);
    }

}

My guess is that Math.Lerp does some mathematics (subtraction, multiplication) while AnimationCurves work on the premise of a lookup table. This explains why the first lookup takes longer - it needs to put the curve into memory.

I wonder how precise the curve is though - I would have expected a Lerp between points in the lookup table but that should not have the results you are showing…

Always wanted to find the clear reason for this. But I simply use Animation Curves. They are more flexible and easy to code.

I just ran a test on the precision, stepping from 0.5f to 1.5f across 1,000,000 steps, with a flat AnimationCurve.

Summing up the Abs of the errors when comparing the result of Eval to just “t+0.5f” resulted in a cumulative error of 0.01843636, so per evaluation the error was about 1 in 10 million, which to me seems suspiciously close to just floating point imprecision. Interestingly, this error was precisely the same when evaluating with Mathf.Lerp. (When I ran the test from 0f to 1f, the reported cumulative error was 0, further strengthening the theory that this is just floating point imprecision.)

In terms of timing, running the test with AnimationCurve vs Lerp resulted in a small time difference between the two: 1342537 ticks for AnimationCurve, 1396620 ticks for Lerp.

Some weirder results: When testing a simple curve (flat 0 to 1) with 10000 steps vs a more complex curve (a funky S thing with 4 Keyframes), the simple one took 20206 ticks vs the complex one’s 9967 ticks. When I changed the order in which the tests were done, they took about the same time, though the simple curve still took slightly longer. So it seems like the AnimationCurve class itself takes some time to get warmed up, not each individual AnimationCurve. These results are not consistent with the lookup table hypothesis, IMO.

I did try adding tons of keyframes, and as expected that does slow the function down, but only by a tiny amount, and still very close to the Lerp baseline.

Conclusion: AnimationCurve is insanely well optimized and Lerp ought to switch to using it internally.

Here’s my basic code (of course, the different tests had small lines changed but most follow the same structure):

public class LerpTest : MonoBehaviour
{
    public int steps = 10000;
    public AnimationCurve curve = new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(1f, 1f));
    [ContextMenu("Run Test")]
    void RunTest()
    {
        long testStart = System.DateTime.Now.Ticks;
        float stepSize = 1f / steps;
        int stepsWithErr = 0;
        float cumError = 0f;
        for (float t=0f;t<1f;t += stepSize)
        {
            float eval = curve.Evaluate(t);
            float err = eval - (t+0.5f);
            if (!Mathf.Approximately(err, eval))
            {
//                Debug.Log($"At {t}, eval was {eval}, an error of {err}");
                stepsWithErr++;
                cumError += Mathf.Abs(err);
            }
        }
        long testEnd = System.DateTime.Now.Ticks;
        long testTicks = testEnd - testStart;
        Debug.Log($"AnimationCurve: Found {stepsWithErr} errors in {steps} steps totaling to {cumError}. This took {testTicks} ticks.");
        testStart = System.DateTime.Now.Ticks;
        stepSize = 1f / steps;
        stepsWithErr = 0;
        cumError = 0f;
        for (float t = 0f; t < 1f; t += stepSize)
        {
            float eval = Mathf.Lerp(0.5f, 1.5f, t);
            float err = eval - (t+0.5f);
            if (!Mathf.Approximately(err, eval))
            {
                //                Debug.Log($"At {t}, eval was {eval}, an error of {err}");
                stepsWithErr++;
                cumError += Mathf.Abs(err);
            }
        }
        testEnd = System.DateTime.Now.Ticks;
        testTicks = testEnd - testStart;
        Debug.Log($"Lerp: Found {stepsWithErr} errors in {steps} steps totaling to {cumError}. This took {testTicks} ticks.");
    }
}
1 Like

Is it possible this has to do with clamping? The documentation for Mathf.Lerp says that it clamps t, but the documentation for AnimationCurve.Evaluate does not mention clamping. (Apologies for my laziness in not actually testing, but I don’t have any experience with animation curves.)

Given the surprisingness of this result, it might be worth rolling your own simple implementation of Lerp and comparing that to Mathf.Lerp, to check if Mathf.Lerp is particularly inefficient for some reason.

float MyLerpUnclamped(float a, float b, float t)
{
    return a + t*(b-a);
}

float MyLerpClamped(float a, float b, float t)
{
    if (t < 0) return a;
    if (t > 1) return b;
    return a + t*(b-a);
}

I get 149928 ticks for Mathf.Lerp, 129647 for MyLerpClamped, and 129652 for MyLerpUnclamped. (Presumably it’s just that it’s within the margin of variation for measurement.)

My guess as to the slowness of Mathf.Lerp in this test is the overhead of crossing the C#-C++ line, but that doesn’t explain how AnimationCurve.Evaluate could be faster.

Looking in the Assembly Browser on Visual Studio for Mac, most of the Mathf class, including Mathf.Lerp, is implemented entirely on the C# side, whereas a large portion of the AnimationCurve class, including the Evaluate function, is being interop’d; assuming I understand what this means correctly:

[MethodImpl (MethodImplOptions.InternalCall)]
[ThreadSafe]
public extern float Evaluate (float time);

That said, Mathf.Lerp also makes a call to Mathf.Clamp01 on the passed-in t value every time (if less than 0, return 0, if greater than 1, return 1). Might be the extra method call and/or the conditional is the source of the extra overhead?

1 Like

Ok, so it’s not a lookup but a really efficient implementation that is faster than the comparatively simple Lerp-implementation in bytecode even though there is the interop overhead. That does make the warmup even stranger, IMHO, because a warmup could also be explained by JIT-compiling the implementation. Are interop calls cached somehow? That would explain the warmup. What I mean by “cached” in this case is something like the initial lookup of the address of the native method may be complex / take some time but once that first call was done, no more lookup for the method address needed, so calls are much faster. That does seem plausible to me but I never looked into how interop calls actually work, tbh.

Since we are speculating: one possible reason for the relative slowness of Lerp vs Curve is that Lerp calls out to lib routines, causing cache misses and pipe-flushes on branch and return, while the curve code is mroe starightforward and can complete with fewer such events.The warmup could be explained by JIT, but only if on two successive different curves only the first takes a hit.