Skip to content

Keep per-vertex math out of iOS interpreter - #206

Merged
HelloYeew merged 2 commits into
mainfrom
per-vertex-math-coreclr-override
Oct 27, 2026
Merged

HelloYeew merged 2 commits into
mainfrom
per-vertex-math-coreclr-override

Conversation

@HelloYeew

Copy link
Copy Markdown
Owner

Continue of #205

This is a really long issues that I am finding why and found it....

On iOS, .NET 11 never precompiles float.MultiplyAddEstimate and iOS don't have JIT (android have JIT). So in CoreCLR's intepreter, Sakura is a graphic framework so every frame we use Vector2.Transform, Vector2.Lerp and Matrix3x2 product a lot since it's a heart of every drawable. So this make an iOS performance on yuuki a lot worse (frame drop) a lot more than I expected.

Note : this already got fixed and merged on the runtime repo so waiting for it to come in .NET 12 preview

Results on yuuki. (iPhone 16 Pro Max, same 150 s session)

Before After
Update thread time in the interpreter 85.9% 2.8%
Total CPU 154 s 109 s
Gameplay 85–100 fps 120 fps
Thermal Fair after 51 s Nominal throughout
instruments-before-vs-after

About android, this fix got benefit on android too. Measured using this code, attached it in yuuki code for testing

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using Sakura.Framework.Logging;
using Sakura.Framework.Maths;
using SM = System.Numerics.Matrix3x2;
using SV2 = System.Numerics.Vector2;

namespace Yuuki.Game;

/// <summary>
/// ns per call of the per-vertex math on this device: "old" is the generated forward as it reads (System.Numerics
/// through the implicit conversions), "new" whatever Sakura's member is in this build, "raw" System.Numerics alone.
/// </summary>
public static class MathBench
{
    private const int n = 4_000_000;

    // Iterations per call: short while warming up so every loop is called often enough to be recompiled at tier 1.
    private static int count = n;
    private const int verts = 4096;

    private static readonly Matrix3x2 m = new Matrix3x2(1.01f, 0.02f, -0.03f, 0.99f, 3f, 4f);
    private static readonly SM sm = m.Value;
    private static readonly Vector2[] src = new Vector2[verts];
    private static readonly Vector2[] dst = new Vector2[verts];
    private static readonly SV2[] ssrc = new SV2[verts];
    private static readonly SV2[] sdst = new SV2[verts];

    public static void Start() => new Thread(() =>
    {
        Thread.Sleep(8000);
        run();
    }) { IsBackground = true, Name = "MathBench" }.Start();

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Vector2 oldTransform(Vector2 position, Matrix3x2 matrix) => SV2.Transform(position, matrix);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Vector2 oldLerp(Vector2 value1, Vector2 value2, float amount) => SV2.Lerp(value1, value2, amount);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Matrix3x2 oldMultiply(Matrix3x2 value1, Matrix3x2 value2) => value1.Value * value2.Value;

    private static double measure(int calls, Func<float> body)
    {
        body();
        body();
        double best = double.MaxValue;

        for (int r = 0; r < 7; r++)
        {
            long start = Stopwatch.GetTimestamp();
            float sink = body();
            double ns = Stopwatch.GetElapsedTime(start).TotalNanoseconds / calls;
            best = Math.Min(best, ns);
            if (sink == 12345.678f) Logger.Verbose("");
        }

        return best;
    }

    private static void run()
    {
        for (int i = 0; i < verts; i++)
        {
            src[i] = new Vector2(i, i * 0.5f);
            ssrc[i] = src[i];
        }

        // Cold: as precompiled (ReadyToRun) code runs before the JIT recompiles it, and as rarely called code always runs.
        log("cold");

        // Hot: called often enough, with time for the tier-1 recompile, as per-frame drawing code ends up.
        count = verts * 2;
        for (int round = 0; round < 2; round++)
        {
            foreach (var body in all())
                for (int c = 0; c < 60; c++)
                    body();
            Thread.Sleep(1500);
        }

        count = n;
        log("hot");
    }

    private static Func<float>[] all() => new Func<float>[]
    {
        oldTransformChain, newTransformChain, rawTransformChain, oldTransformArray, newTransformArray, rawTransformArray,
        oldLerpChain, newLerpChain, rawLerpChain, oldMultiplyChain, newMultiplyChain, rawMultiplyChain,
    };

    private static void log(string phase)
    {
        Logger.Verbose($"bench {phase} (ns/call old/new/raw)"
                       + $" | Transform chain {measure(n, oldTransformChain):F2}/{measure(n, newTransformChain):F2}/{measure(n, rawTransformChain):F2}"
                       + $" | Transform array {measure(n, oldTransformArray):F2}/{measure(n, newTransformArray):F2}/{measure(n, rawTransformArray):F2}"
                       + $" | Lerp chain {measure(n, oldLerpChain):F2}/{measure(n, newLerpChain):F2}/{measure(n, rawLerpChain):F2}"
                       + $" | Matrix3x2 * chain {measure(n, oldMultiplyChain):F2}/{measure(n, newMultiplyChain):F2}/{measure(n, rawMultiplyChain):F2}");
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float oldTransformChain() { var v = new Vector2(1, 2); for (int i = 0; i < count; i++) v = oldTransform(v, m) * 0.5f; return v.X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float newTransformChain() { var v = new Vector2(1, 2); for (int i = 0; i < count; i++) v = Vector2.Transform(v, m) * 0.5f; return v.X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float rawTransformChain() { var v = new SV2(1, 2); for (int i = 0; i < count; i++) v = SV2.Transform(v, sm) * 0.5f; return v.X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float oldTransformArray() { for (int k = 0; k < count / verts; k++) for (int i = 0; i < verts; i++) dst[i] = oldTransform(src[i], m); return dst[7].X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float newTransformArray() { for (int k = 0; k < count / verts; k++) for (int i = 0; i < verts; i++) dst[i] = Vector2.Transform(src[i], m); return dst[7].X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float rawTransformArray() { for (int k = 0; k < count / verts; k++) for (int i = 0; i < verts; i++) sdst[i] = SV2.Transform(ssrc[i], sm); return sdst[7].X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float oldLerpChain() { var v = new Vector2(1, 2); var t = new Vector2(5, 7); for (int i = 0; i < count; i++) v = oldLerp(v, t, 0.25f); return v.X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float newLerpChain() { var v = new Vector2(1, 2); var t = new Vector2(5, 7); for (int i = 0; i < count; i++) v = Vector2.Lerp(v, t, 0.25f); return v.X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float rawLerpChain() { var v = new SV2(1, 2); var t = new SV2(5, 7); for (int i = 0; i < count; i++) v = SV2.Lerp(v, t, 0.25f); return v.X; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float oldMultiplyChain() { var a = m; for (int i = 0; i < count; i++) a = oldMultiply(a, m); return a.M11; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float newMultiplyChain() { var a = m; for (int i = 0; i < count; i++) a = a * m; return a.M11; }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static float rawMultiplyChain() { var a = sm; for (int i = 0; i < count; i++) a = a * sm; return a.M11; }
}
  • Cold: as precompiled (R2R) code runs. Each bench method is called only 9 times, below the tier-up threshold.
  • Hot: after forcing tier 1, by calling every method 120 times with short loops and sleeping 1.5 s twice.
Cold: old -> new Hot: old -> new
Transform, 4096 independent vertices 6.22 -> 0.76 (8.2×) 0.76 -> 0.40 (1.9×)
Transform, dependent chain 18.6 -> 7.1 10.2 -> 7.5
Lerp, chain 12.1 -> 2.0 (6×) 3.4 -> 2.0
Matrix3x2 *, chain 13.5 -> 6.5 (2×) 14.0 -> 6.4

@HelloYeew
HelloYeew merged commit 24eef34 into main Oct 27, 2026
9 checks passed
@HelloYeew
HelloYeew deleted the per-vertex-math-coreclr-override branch October 27, 2026 04:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant