From c0c4741ce22170a4d14f5456acfc4907f64469c3 Mon Sep 17 00:00:00 2001 From: eduardgushchin Date: Thu, 24 Sep 2026 21:43:27 +0300 Subject: [PATCH 1/4] perf: reuse per-world solver step buffers --- src/Box2D.NET/B2Solvers.cs | 3 ++- src/Box2D.NET/B2StepContext.cs | 28 ++++++++++++++++++++++++++++ src/Box2D.NET/B2World.cs | 2 ++ src/Box2D.NET/B2Worlds.cs | 3 ++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Box2D.NET/B2Solvers.cs b/src/Box2D.NET/B2Solvers.cs index 2c5d7ee9..651d37a3 100644 --- a/src/Box2D.NET/B2Solvers.cs +++ b/src/Box2D.NET/B2Solvers.cs @@ -1538,7 +1538,8 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) b2InitBlocks(jointBlocks, jointPrepareDim, jointCount, B2SolverBlockType.b2_jointBlock, byte.MaxValue); // Prepare graph work blocks. Each color gets joint blocks followed by contact blocks. - ArraySegment[] graphColorBlocks = new ArraySegment[B2_GRAPH_COLOR_COUNT]; + ArraySegment[] graphColorBlocks = world.reusableGraphColorBlocks; + Array.Clear(graphColorBlocks); ArraySegment baseGraphBlock = graphBlocks; B2FixedArray24 arrayGraphBlockCounts = new B2FixedArray24(); Span graphBlockCounts = arrayGraphBlockCounts.AsSpan(); diff --git a/src/Box2D.NET/B2StepContext.cs b/src/Box2D.NET/B2StepContext.cs index 8901936d..398da795 100644 --- a/src/Box2D.NET/B2StepContext.cs +++ b/src/Box2D.NET/B2StepContext.cs @@ -9,6 +9,34 @@ namespace Box2D.NET // Context for a time step. Recreated each time step. public class B2StepContext // TODO: @ikpil, check struct or class { + internal void Reset() + { + dt = inv_dt = h = inv_h = 0; + subStepCount = 0; + contactSoftness = staticSoftness = default; + restitutionThreshold = maxLinearVelocity = 0; + world = null; + graph = default; + states = null; + sims = null; + enlargedShapes = null; + enlargedShapeCount = 0; + bulletBodies = default; + bulletBodyCount = default; + contactSims = default; + wideContactConstraints = default; + contactPrepareSpans = null; + wideContactCount = 0; + jointPrepareSpans = null; + jointCount = 0; + activeColorCount = workerCount = 0; + stages = default; + stageCount = 0; + enableWarmStarting = false; + atomicSyncBits = default; + mainClaimed = default; + } + // time step public float dt; diff --git a/src/Box2D.NET/B2World.cs b/src/Box2D.NET/B2World.cs index 38bbe219..4ef2d1ac 100644 --- a/src/Box2D.NET/B2World.cs +++ b/src/Box2D.NET/B2World.cs @@ -11,6 +11,8 @@ namespace Box2D.NET // The world also contains efficient memory management facilities. public class B2World { + internal readonly B2StepContext reusableStepContext = new B2StepContext(); + internal readonly System.ArraySegment[] reusableGraphColorBlocks = new System.ArraySegment[B2Constants.B2_GRAPH_COLOR_COUNT]; public B2StackAllocator stack; public B2BroadPhase broadPhase; public B2ConstraintGraph constraintGraph; diff --git a/src/Box2D.NET/B2Worlds.cs b/src/Box2D.NET/B2Worlds.cs index 353ab62b..e3a3eb28 100644 --- a/src/Box2D.NET/B2Worlds.cs +++ b/src/Box2D.NET/B2Worlds.cs @@ -936,7 +936,8 @@ public static void b2World_Step(B2WorldId worldId, float timeStep, int subStepCo world.profile.pairs = b2GetMilliseconds(pairTicks); } - B2StepContext context = new B2StepContext(); + B2StepContext context = world.reusableStepContext; + context.Reset(); context.world = world; context.dt = timeStep; context.subStepCount = b2MaxInt(1, subStepCount); From 0cb59c855c8abdff03b2f0db4d758c10c3f8aee0 Mon Sep 17 00:00:00 2001 From: eduardgushchin Date: Wed, 30 Sep 2026 13:10:21 +0300 Subject: [PATCH 2/4] perf: avoid static revolute state allocations --- src/Box2D.NET/B2RevoluteJoints.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Box2D.NET/B2RevoluteJoints.cs b/src/Box2D.NET/B2RevoluteJoints.cs index e5b0a7bf..17be1d84 100644 --- a/src/Box2D.NET/B2RevoluteJoints.cs +++ b/src/Box2D.NET/B2RevoluteJoints.cs @@ -282,8 +282,8 @@ internal static void b2WarmStartRevoluteJoint(B2JointSim @base, B2StepContext co float iA = @base.invIA; float iB = @base.invIB; - // dummy state for static bodies - B2BodyState dummyState = B2BodyState.Create(b2_identityBodyState); + // Static-body branches only read this shared identity state. + B2BodyState dummyState = b2_identityBodyState; ref readonly B2RevoluteJoint joint = ref @base.uj.revoluteJoint; B2BodyState stateA = joint.indexA == B2_NULL_INDEX ? dummyState : context.states[joint.indexA]; @@ -316,8 +316,8 @@ internal static void b2SolveRevoluteJoint(B2JointSim @base, B2StepContext contex float iA = @base.invIA; float iB = @base.invIB; - // dummy state for static bodies - B2BodyState dummyState = B2BodyState.Create(b2_identityBodyState); + // Static-body branches only read this shared identity state. + B2BodyState dummyState = b2_identityBodyState; ref B2RevoluteJoint joint = ref @base.uj.revoluteJoint; From a3bc03f1f375d9e96c87755420d498e1a2b62ca2 Mon Sep 17 00:00:00 2001 From: eduardgushchin Date: Wed, 30 Sep 2026 14:11:30 +0300 Subject: [PATCH 3/4] perf: avoid static wheel state allocations --- src/Box2D.NET/B2WheelJoints.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Box2D.NET/B2WheelJoints.cs b/src/Box2D.NET/B2WheelJoints.cs index 64033ada..d172cc7c 100644 --- a/src/Box2D.NET/B2WheelJoints.cs +++ b/src/Box2D.NET/B2WheelJoints.cs @@ -277,8 +277,8 @@ internal static void b2WarmStartWheelJoint(B2JointSim @base, B2StepContext conte float iA = @base.invIA; float iB = @base.invIB; - // dummy state for static bodies - B2BodyState dummyState = B2BodyState.Create(b2_identityBodyState); + // Static-body branches only read this shared identity state. + B2BodyState dummyState = b2_identityBodyState; ref readonly B2WheelJoint joint = ref @base.uj.wheelJoint; @@ -326,8 +326,8 @@ internal static void b2SolveWheelJoint(B2JointSim @base, B2StepContext context, float iA = @base.invIA; float iB = @base.invIB; - // dummy state for static bodies - B2BodyState dummyState = B2BodyState.Create(b2_identityBodyState); + // Static-body branches only read this shared identity state. + B2BodyState dummyState = b2_identityBodyState; ref B2WheelJoint joint = ref @base.uj.wheelJoint; From 54af84ed8a16fe73b8ee14361af877f202dc71c1 Mon Sep 17 00:00:00 2001 From: eduardgushchin Date: Wed, 7 Oct 2026 01:27:46 +0300 Subject: [PATCH 4/4] perf: vectorize contacts and retain world step storage --- src/Box2D.NET/B2Arrays.cs | 3 +- src/Box2D.NET/B2ContactConstraintWide.cs | 4 +- src/Box2D.NET/B2ContactSolvers.cs | 266 ++++++++++------- src/Box2D.NET/B2Contacts.cs | 2 +- src/Box2D.NET/B2Cores.cs | 2 +- src/Box2D.NET/B2FloatW.cs | 40 ++- src/Box2D.NET/B2Islands.cs | 27 +- src/Box2D.NET/B2ParallelFor.cs | 108 +++++-- src/Box2D.NET/B2ParallelForShared.cs | 12 + src/Box2D.NET/B2RuntimeValidator.cs | 2 +- src/Box2D.NET/B2SolverSets.cs | 27 +- src/Box2D.NET/B2Solvers.cs | 123 +++++--- src/Box2D.NET/B2StepContext.cs | 5 +- src/Box2D.NET/B2World.cs | 14 +- src/Box2D.NET/B2Worlds.cs | 276 +++++++++--------- src/Box2D.NET/Box2D.NET.csproj | 2 + test/Box2D.NET.Test/B2StepReuseTests.cs | 197 +++++++++++++ test/Box2D.NET.Test/B2WideArithmeticTests.cs | 81 +++++ .../Box2D.NET.Benchmark/DENSE_PERFORMANCE.md | 111 +++++++ tools/Box2D.NET.Benchmark/DenseBenchmark.cs | 132 +++++++++ tools/Box2D.NET.Benchmark/Program.cs | 5 + tools/Box2D.NET.Benchmark/README.md | 4 + .../results/dense-5700x-net10-noavx.json | 1 + .../results/dense-5700x-net10.json | 254 ++++++++++++++++ 24 files changed, 1348 insertions(+), 350 deletions(-) create mode 100644 test/Box2D.NET.Test/B2StepReuseTests.cs create mode 100644 test/Box2D.NET.Test/B2WideArithmeticTests.cs create mode 100644 tools/Box2D.NET.Benchmark/DENSE_PERFORMANCE.md create mode 100644 tools/Box2D.NET.Benchmark/DenseBenchmark.cs create mode 100644 tools/Box2D.NET.Benchmark/results/dense-5700x-net10-noavx.json create mode 100644 tools/Box2D.NET.Benchmark/results/dense-5700x-net10.json diff --git a/src/Box2D.NET/B2Arrays.cs b/src/Box2D.NET/B2Arrays.cs index 6190712b..96ac05d2 100644 --- a/src/Box2D.NET/B2Arrays.cs +++ b/src/Box2D.NET/B2Arrays.cs @@ -105,12 +105,13 @@ public static void b2Array_Set(ref B2Array a, int index, T value) if (index != a.count - 1) { movedIndex = a.count - 1; + T removed = a.data[index]; a.data[index] = a.data[movedIndex]; // fixed, ikpil if (!typeof(T).IsValueType) { - a.data[movedIndex] = new T(); + a.data[movedIndex] = removed; } } diff --git a/src/Box2D.NET/B2ContactConstraintWide.cs b/src/Box2D.NET/B2ContactConstraintWide.cs index 02e7708e..178924e4 100644 --- a/src/Box2D.NET/B2ContactConstraintWide.cs +++ b/src/Box2D.NET/B2ContactConstraintWide.cs @@ -10,8 +10,8 @@ namespace Box2D.NET // https://box2d.org/files/ErinCatto_SoftConstraints_GDC2011.pdf public struct B2ContactConstraintWide { - public B2FixedArray4 indexA; // = new int[B2_SIMD_WIDTH]; - public B2FixedArray4 indexB; // = new int[B2_SIMD_WIDTH]; + public B2FixedArray8 indexA; // = new int[B2_SIMD_WIDTH]; + public B2FixedArray8 indexB; // = new int[B2_SIMD_WIDTH]; public B2FloatW invMassA, invMassB; public B2FloatW invIA, invIB; diff --git a/src/Box2D.NET/B2ContactSolvers.cs b/src/Box2D.NET/B2ContactSolvers.cs index 0334fd8f..ba345720 100644 --- a/src/Box2D.NET/B2ContactSolvers.cs +++ b/src/Box2D.NET/B2ContactSolvers.cs @@ -4,6 +4,9 @@ using System; using System.Numerics; +#if NET8_0_OR_GREATER +using System.Runtime.Intrinsics; +#endif using System.Runtime.CompilerServices; using static Box2D.NET.B2Arrays; using static Box2D.NET.B2Cores; @@ -175,6 +178,11 @@ internal static void b2WarmStartContacts_Overflow(B2StepContext context) ref B2GraphColor color = ref graph.colors[B2_OVERFLOW_INDEX]; Span constraints = color.overflowConstraints; int contactCount = color.contactSims.count; + if (contactCount == 0) + { + b2TracyCZoneEnd(B2TracyCZone.warmstart_overflow_contact); + return; + } B2World world = context.world; B2SolverSet awakeSet = b2Array_Get(ref world.solverSets, (int)B2SolverSetType.b2_awakeSet); B2BodyState[] states = awakeSet.bodyStates.data; @@ -252,6 +260,11 @@ internal static void b2SolveContacts_Overflow(B2StepContext context, bool useBia ref B2GraphColor color = ref graph.colors[B2_OVERFLOW_INDEX]; Span constraints = color.overflowConstraints; int contactCount = color.contactSims.count; + if (contactCount == 0) + { + b2TracyCZoneEnd(B2TracyCZone.solve_contact); + return; + } B2World world = context.world; B2SolverSet awakeSet = b2Array_Get(ref world.solverSets, (int)B2SolverSetType.b2_awakeSet); B2BodyState[] states = awakeSet.bodyStates.data; @@ -423,6 +436,11 @@ internal static void b2ApplyRestitution_Overflow(B2StepContext context) ref B2GraphColor color = ref graph.colors[B2_OVERFLOW_INDEX]; Span constraints = color.overflowConstraints; int contactCount = color.contactSims.count; + if (contactCount == 0) + { + b2TracyCZoneEnd(B2TracyCZone.overflow_resitution); + return; + } B2World world = context.world; B2SolverSet awakeSet = b2Array_Get(ref world.solverSets, (int)B2SolverSetType.b2_awakeSet); B2BodyState[] states = awakeSet.bodyStates.data; @@ -648,132 +666,187 @@ public static bool b2AllZeroW(Vector a) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2ZeroW() { - return new B2FloatW(0.0f, 0.0f, 0.0f, 0.0f); + return default; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2SplatW(float scalar) { - return new B2FloatW(scalar, scalar, scalar, scalar); + return new B2FloatW(scalar, scalar, scalar, scalar, scalar, scalar, scalar, scalar); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2AddW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW(a.X + b.X, a.Y + b.Y, a.Z + b.Z, a.W + b.W); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Unsafe.BitCast>(a) + Unsafe.BitCast>(b)); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] + b[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2SubW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW(a.X - b.X, a.Y - b.Y, a.Z - b.Z, a.W - b.W); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Unsafe.BitCast>(a) - Unsafe.BitCast>(b)); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] - b[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2MulW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW(a.X * b.X, a.Y * b.Y, a.Z * b.Z, a.W * b.W); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Unsafe.BitCast>(a) * Unsafe.BitCast>(b)); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] * b[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2MulAddW(in B2FloatW a, in B2FloatW b, in B2FloatW c) { - return new B2FloatW(a.X + b.X * c.X, a.Y + b.Y * c.Y, a.Z + b.Z * c.Z, a.W + b.W * c.W); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Unsafe.BitCast>(a) + Unsafe.BitCast>(b) * Unsafe.BitCast>(c)); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] + b[i] * c[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2MulSubW(in B2FloatW a, in B2FloatW b, in B2FloatW c) { - return new B2FloatW(a.X - b.X * c.X, a.Y - b.Y * c.Y, a.Z - b.Z * c.Z, a.W - b.W * c.W); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Unsafe.BitCast>(a) - Unsafe.BitCast>(b) * Unsafe.BitCast>(c)); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] - b[i] * c[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2MinW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW( - a.X <= b.X ? a.X : b.X, - a.Y <= b.Y ? a.Y : b.Y, - a.Z <= b.Z ? a.Z : b.Z, - a.W <= b.W ? a.W : b.W - ); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Vector256.ConditionalSelect(Vector256.LessThanOrEqual(Unsafe.BitCast>(a), Unsafe.BitCast>(b)), Unsafe.BitCast>(a), Unsafe.BitCast>(b))); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] <= b[i] ? a[i] : b[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2MaxW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW( - a.X >= b.X ? a.X : b.X, - a.Y >= b.Y ? a.Y : b.Y, - a.Z >= b.Z ? a.Z : b.Z, - a.W >= b.W ? a.W : b.W - ); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Vector256.ConditionalSelect(Vector256.GreaterThanOrEqual(Unsafe.BitCast>(a), Unsafe.BitCast>(b)), Unsafe.BitCast>(a), Unsafe.BitCast>(b))); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] >= b[i] ? a[i] : b[i]; + return result; +#endif } // a = clamp(a, -b, b) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2SymClampW(in B2FloatW a, in B2FloatW b) { - // a = clamp(a, -b, b) - return new B2FloatW( - b2ClampFloat(a.X, -b.X, b.X), - b2ClampFloat(a.Y, -b.Y, b.Y), - b2ClampFloat(a.Z, -b.Z, b.Z), - b2ClampFloat(a.W, -b.W, b.W) - ); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Vector256.ConditionalSelect(Vector256.LessThan(Unsafe.BitCast>(a), -Unsafe.BitCast>(b)), -Unsafe.BitCast>(b), + Vector256.ConditionalSelect(Vector256.GreaterThan(Unsafe.BitCast>(a), Unsafe.BitCast>(b)), Unsafe.BitCast>(b), Unsafe.BitCast>(a)))); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] < -b[i] ? -b[i] : a[i] > b[i] ? b[i] : a[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2OrW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW( - a.X != 0.0f || b.X != 0.0f ? 1.0f : 0.0f, - a.Y != 0.0f || b.Y != 0.0f ? 1.0f : 0.0f, - a.Z != 0.0f || b.Z != 0.0f ? 1.0f : 0.0f, - a.W != 0.0f || b.W != 0.0f ? 1.0f : 0.0f - ); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>((~Vector256.Equals(Unsafe.BitCast>(a), Vector256.Zero) | ~Vector256.Equals(Unsafe.BitCast>(b), Vector256.Zero)) & Vector256.One); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] != 0 || b[i] != 0 ? 1 : 0; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2GreaterThanW(in B2FloatW a, in B2FloatW b) { - return new B2FloatW( - a.X > b.X ? 1.0f : 0.0f, - a.Y > b.Y ? 1.0f : 0.0f, - a.Z > b.Z ? 1.0f : 0.0f, - a.W > b.W ? 1.0f : 0.0f - ); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Vector256.GreaterThan(Unsafe.BitCast>(a), Unsafe.BitCast>(b)) & Vector256.One); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] > b[i] ? 1 : 0; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2EqualsW(in B2FloatW a, in B2FloatW b) { - // TODO: @ikpil check float equal - return new B2FloatW( - a.X == b.X ? 1.0f : 0.0f, - a.Y == b.Y ? 1.0f : 0.0f, - a.Z == b.Z ? 1.0f : 0.0f, - a.W == b.W ? 1.0f : 0.0f - ); +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Vector256.Equals(Unsafe.BitCast>(a), Unsafe.BitCast>(b)) & Vector256.One); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = a[i] == b[i] ? 1 : 0; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool b2AllZeroW(in B2FloatW a) { - return a.X == 0.0f && a.Y == 0.0f && a.Z == 0.0f && a.W == 0.0f; +#if NET8_0_OR_GREATER + return Vector256.EqualsAll(Unsafe.BitCast>(a), Vector256.Zero); +#else + for (int i = 0; i < B2_SIMD_WIDTH; i++) + { + if (a[i] != 0) + return false; + } + return true; +#endif } // component-wise returns mask ? b : a [MethodImpl(MethodImplOptions.AggressiveInlining)] public static B2FloatW b2BlendW(in B2FloatW a, in B2FloatW b, in B2FloatW mask) { - // component-wise returns mask ? b : a - return new B2FloatW() - { - X = mask.X != 0.0f ? b.X : a.X, - Y = mask.Y != 0.0f ? b.Y : a.Y, - Z = mask.Z != 0.0f ? b.Z : a.Z, - W = mask.W != 0.0f ? b.W : a.W, - }; +#if NET8_0_OR_GREATER + return Unsafe.BitCast, B2FloatW>(Vector256.ConditionalSelect(Vector256.Equals(Unsafe.BitCast>(mask), Vector256.Zero), Unsafe.BitCast>(a), Unsafe.BitCast>(b))); +#else + B2FloatW result = default; + for (int i = 0; i < B2_SIMD_WIDTH; i++) + result[i] = mask[i] != 0 ? b[i] : a[i]; + return result; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -803,7 +876,7 @@ public static int b2GetWideContactConstraintByteCount() } -// Custom gather/scatter for each SIMD type + // Custom gather/scatter for each SIMD type #if B2_SIMD_AVX2 // This is a load and 8x8 transpose static b2BodyStateW b2GatherBodies( const b2BodyState* states, int* indices ) @@ -1183,21 +1256,28 @@ internal static B2BodyStateW b2GatherBodies(ReadOnlySpan states, Re int i2 = indices[1] - 1; int i3 = indices[2] - 1; int i4 = indices[3] - 1; - + int i5 = indices[4] - 1; + int i6 = indices[5] - 1; + int i7 = indices[6] - 1; + int i8 = indices[7] - 1; B2BodyState s1 = i1 == B2_NULL_INDEX ? identity : states[i1]; B2BodyState s2 = i2 == B2_NULL_INDEX ? identity : states[i2]; B2BodyState s3 = i3 == B2_NULL_INDEX ? identity : states[i3]; B2BodyState s4 = i4 == B2_NULL_INDEX ? identity : states[i4]; + B2BodyState s5 = i5 == B2_NULL_INDEX ? identity : states[i5]; + B2BodyState s6 = i6 == B2_NULL_INDEX ? identity : states[i6]; + B2BodyState s7 = i7 == B2_NULL_INDEX ? identity : states[i7]; + B2BodyState s8 = i8 == B2_NULL_INDEX ? identity : states[i8]; B2BodyStateW simdBody = new B2BodyStateW(); - simdBody.v.X = new B2FloatW(s1.linearVelocity.X, s2.linearVelocity.X, s3.linearVelocity.X, s4.linearVelocity.X); - simdBody.v.Y = new B2FloatW(s1.linearVelocity.Y, s2.linearVelocity.Y, s3.linearVelocity.Y, s4.linearVelocity.Y); - simdBody.w = new B2FloatW(s1.angularVelocity, s2.angularVelocity, s3.angularVelocity, s4.angularVelocity); - simdBody.flags = new B2FloatW((float)s1.flags, (float)s2.flags, (float)s3.flags, (float)s4.flags); - simdBody.dp.X = new B2FloatW(s1.deltaPosition.X, s2.deltaPosition.X, s3.deltaPosition.X, s4.deltaPosition.X); - simdBody.dp.Y = new B2FloatW(s1.deltaPosition.Y, s2.deltaPosition.Y, s3.deltaPosition.Y, s4.deltaPosition.Y); - simdBody.dq.C = new B2FloatW(s1.deltaRotation.c, s2.deltaRotation.c, s3.deltaRotation.c, s4.deltaRotation.c); - simdBody.dq.S = new B2FloatW(s1.deltaRotation.s, s2.deltaRotation.s, s3.deltaRotation.s, s4.deltaRotation.s); + simdBody.v.X = new B2FloatW(s1.linearVelocity.X, s2.linearVelocity.X, s3.linearVelocity.X, s4.linearVelocity.X, s5.linearVelocity.X, s6.linearVelocity.X, s7.linearVelocity.X, s8.linearVelocity.X); + simdBody.v.Y = new B2FloatW(s1.linearVelocity.Y, s2.linearVelocity.Y, s3.linearVelocity.Y, s4.linearVelocity.Y, s5.linearVelocity.Y, s6.linearVelocity.Y, s7.linearVelocity.Y, s8.linearVelocity.Y); + simdBody.w = new B2FloatW(s1.angularVelocity, s2.angularVelocity, s3.angularVelocity, s4.angularVelocity, s5.angularVelocity, s6.angularVelocity, s7.angularVelocity, s8.angularVelocity); + simdBody.flags = new B2FloatW((float)s1.flags, (float)s2.flags, (float)s3.flags, (float)s4.flags, (float)s5.flags, (float)s6.flags, (float)s7.flags, (float)s8.flags); + simdBody.dp.X = new B2FloatW(s1.deltaPosition.X, s2.deltaPosition.X, s3.deltaPosition.X, s4.deltaPosition.X, s5.deltaPosition.X, s6.deltaPosition.X, s7.deltaPosition.X, s8.deltaPosition.X); + simdBody.dp.Y = new B2FloatW(s1.deltaPosition.Y, s2.deltaPosition.Y, s3.deltaPosition.Y, s4.deltaPosition.Y, s5.deltaPosition.Y, s6.deltaPosition.Y, s7.deltaPosition.Y, s8.deltaPosition.Y); + simdBody.dq.C = new B2FloatW(s1.deltaRotation.c, s2.deltaRotation.c, s3.deltaRotation.c, s4.deltaRotation.c, s5.deltaRotation.c, s6.deltaRotation.c, s7.deltaRotation.c, s8.deltaRotation.c); + simdBody.dq.S = new B2FloatW(s1.deltaRotation.s, s2.deltaRotation.s, s3.deltaRotation.s, s4.deltaRotation.s, s5.deltaRotation.s, s6.deltaRotation.s, s7.deltaRotation.s, s8.deltaRotation.s); return simdBody; } @@ -1205,44 +1285,15 @@ internal static B2BodyStateW b2GatherBodies(ReadOnlySpan states, Re // This writes only the velocities back to the solver bodies internal static void b2ScatterBodies(ReadOnlySpan states, ReadOnlySpan indices, ref B2BodyStateW simdBody) { - B2_VALIDATE(indices[0] >= 0 && indices[1] >= 0 && indices[2] >= 0 && indices[3] >= 0); - - // zero means null - int i1 = indices[0] - 1; - int i2 = indices[1] - 1; - int i3 = indices[2] - 1; - int i4 = indices[3] - 1; - - if (i1 != B2_NULL_INDEX && (states[i1].flags & (uint)B2BodyFlags.b2_dynamicFlag) != 0) - { - B2BodyState state = states[i1]; - state.linearVelocity.X = simdBody.v.X.X; - state.linearVelocity.Y = simdBody.v.Y.X; - state.angularVelocity = simdBody.w.X; - } - - if (i2 != B2_NULL_INDEX && (states[i2].flags & (uint)B2BodyFlags.b2_dynamicFlag) != 0) + for (int lane = 0; lane < B2_SIMD_WIDTH; lane++) { - B2BodyState state = states[i2]; - state.linearVelocity.X = simdBody.v.X.Y; - state.linearVelocity.Y = simdBody.v.Y.Y; - state.angularVelocity = simdBody.w.Y; - } - - if (i3 != B2_NULL_INDEX && (states[i3].flags & (uint)B2BodyFlags.b2_dynamicFlag) != 0) - { - B2BodyState state = states[i3]; - state.linearVelocity.X = simdBody.v.X.Z; - state.linearVelocity.Y = simdBody.v.Y.Z; - state.angularVelocity = simdBody.w.Z; - } - - if (i4 != B2_NULL_INDEX && (states[i4].flags & (uint)B2BodyFlags.b2_dynamicFlag) != 0) - { - B2BodyState state = states[i4]; - state.linearVelocity.X = simdBody.v.X.W; - state.linearVelocity.Y = simdBody.v.Y.W; - state.angularVelocity = simdBody.w.W; + int index = indices[lane] - 1; + if (index == B2_NULL_INDEX || (states[index].flags & (uint)B2BodyFlags.b2_dynamicFlag) == 0) + continue; + B2BodyState state = states[index]; + state.linearVelocity.X = simdBody.v.X[lane]; + state.linearVelocity.Y = simdBody.v.Y[lane]; + state.angularVelocity = simdBody.w[lane]; } } @@ -1530,6 +1581,7 @@ internal static void b2WarmStartContactsTask(B2SolverBlock block, B2StepContext c.totalNormalImpulse1 = b2AddW(c.totalNormalImpulse1, c.normalImpulse1); } + if (!b2AllZeroW(c.normalMass2)) { // fixed anchors B2Vec2W rA = c.anchorA2; @@ -1652,6 +1704,7 @@ internal static void b2SolveContactsTask(B2SolverBlock block, B2StepContext cont } // second point non-penetration constraint + if (!b2AllZeroW(c.normalMass2)) { // moving anchors for current separation B2Vec2W rsA = b2RotateVectorW(bA.dq, c.anchorA2); @@ -1758,6 +1811,7 @@ internal static void b2SolveContactsTask(B2SolverBlock block, B2StepContext cont } // second point friction constraint + if (!b2AllZeroW(c.normalMass2)) { // fixed anchors for Jacobians B2Vec2W rA = c.anchorA2; @@ -1949,14 +2003,14 @@ internal static void b2StoreImpulsesTask(B2SolverBlock block, B2StepContext cont // Loop over block while (wideIndex < endWideIndex) { - int colorWideEndIndex = b2MinInt( spans[colorIndex + 1].start, endWideIndex ); + int colorWideEndIndex = b2MinInt(spans[colorIndex + 1].start, endWideIndex); int colorWideStart = spans[colorIndex].start; int colorContactCount = spans[colorIndex].count; Span contactSims = spans[colorIndex].contacts; // Loop over color - for ( ; wideIndex < colorWideEndIndex; ++wideIndex ) + for (; wideIndex < colorWideEndIndex; ++wideIndex) { ref readonly B2ContactConstraintWide c = ref wideBase[wideIndex]; ref readonly B2FloatW rollingImpulse = ref c.rollingImpulse; @@ -1968,14 +2022,14 @@ internal static void b2StoreImpulsesTask(B2SolverBlock block, B2StepContext cont ref readonly B2FloatW totalNormalImpulse2 = ref c.totalNormalImpulse2; ref readonly B2FloatW normalVelocity1 = ref c.relativeVelocity1; ref readonly B2FloatW normalVelocity2 = ref c.relativeVelocity2; - + int localWideIndex = wideIndex - colorWideStart; int baseIndex = B2_SIMD_WIDTH * localWideIndex; - for ( int laneIndex = 0; laneIndex < B2_SIMD_WIDTH; ++laneIndex ) + for (int laneIndex = 0; laneIndex < B2_SIMD_WIDTH; ++laneIndex) { int contactIndex = baseIndex + laneIndex; - if ( contactIndex >= colorContactCount ) + if (contactIndex >= colorContactCount) { break; } diff --git a/src/Box2D.NET/B2Contacts.cs b/src/Box2D.NET/B2Contacts.cs index db8b6583..b4f30ef6 100644 --- a/src/Box2D.NET/B2Contacts.cs +++ b/src/Box2D.NET/B2Contacts.cs @@ -197,7 +197,7 @@ public static void b2CreateContact(B2World world, B2Shape shapeA, B2Shape shapeB int contactId = b2AllocId(world.contactIdPool); if (contactId == world.contacts.count) { - b2Array_Push(ref world.contacts, new B2Contact()); + b2Array_Emplace(ref world.contacts); } int shapeIdA = shapeA.id; diff --git a/src/Box2D.NET/B2Cores.cs b/src/Box2D.NET/B2Cores.cs index 1cbde23f..351f01cf 100644 --- a/src/Box2D.NET/B2Cores.cs +++ b/src/Box2D.NET/B2Cores.cs @@ -12,7 +12,7 @@ namespace Box2D.NET public static class B2Cores { // note: I tried width of 1 and got no performance change - public const int B2_SIMD_WIDTH = 4; + public const int B2_SIMD_WIDTH = 8; /// Get the current version of Box2D public static B2Version b2GetVersion() diff --git a/src/Box2D.NET/B2FloatW.cs b/src/Box2D.NET/B2FloatW.cs index 04462da2..d5f6f506 100644 --- a/src/Box2D.NET/B2FloatW.cs +++ b/src/Box2D.NET/B2FloatW.cs @@ -12,27 +12,47 @@ namespace Box2D.NET [StructLayout(LayoutKind.Sequential)] public struct B2FloatW { - public float X; - public float Y; - public float Z; - public float W; +#if NET8_0_OR_GREATER + private System.Runtime.Intrinsics.Vector256 _value; + public ref float X => ref AsSpan()[0]; + public ref float Y => ref AsSpan()[1]; + public ref float Z => ref AsSpan()[2]; + public ref float W => ref AsSpan()[3]; + public ref float E => ref AsSpan()[4]; + public ref float F => ref AsSpan()[5]; + public ref float G => ref AsSpan()[6]; + public ref float H => ref AsSpan()[7]; +#else + public float X, Y, Z, W, E, F, G, H; +#endif + public B2FloatW(float x, float y, float z, float w) : this(x, y, z, w, 0, 0, 0, 0) { } - public B2FloatW(float x, float y, float z, float w) + public B2FloatW(float x, float y, float z, float w, float e, float f, float g, float h) { +#if NET8_0_OR_GREATER + _value = System.Runtime.Intrinsics.Vector256.Create(x, y, z, w, e, f, g, h); +#else X = x; Y = y; Z = z; W = w; + E = e; + F = f; + G = g; + H = h; +#endif } - // readonly so an "in" or "ref readonly" receiver does not force a defensive - // copy. This matches Span.this[int], which is also a readonly ref T indexer. - public readonly ref float this[int index] => ref MemoryMarshal.CreateSpan(ref Unsafe.AsRef(in X), 4)[index]; + public readonly ref float this[int index] => ref AsSpan()[index]; public readonly Span AsSpan() { - return MemoryMarshal.CreateSpan(ref Unsafe.AsRef(in X), 4); +#if NET8_0_OR_GREATER + return MemoryMarshal.CreateSpan(ref Unsafe.As, float>(ref Unsafe.AsRef(in _value)), 8); +#else + return MemoryMarshal.CreateSpan(ref Unsafe.AsRef(in X), 8); +#endif } } -} \ No newline at end of file +} diff --git a/src/Box2D.NET/B2Islands.cs b/src/Box2D.NET/B2Islands.cs index cfa07d27..9c2137eb 100644 --- a/src/Box2D.NET/B2Islands.cs +++ b/src/Box2D.NET/B2Islands.cs @@ -34,7 +34,7 @@ public static B2Island b2CreateIsland(B2World world, int setIndex) if (islandId == world.islands.count) { - b2Array_Push(ref world.islands, new B2Island()); + b2Array_Emplace(ref world.islands); } else { @@ -47,9 +47,9 @@ public static B2Island b2CreateIsland(B2World world, int setIndex) island.setIndex = setIndex; island.localIndex = set.islandSims.count; island.islandId = islandId; - island.bodies = b2Array_Create(); - island.contacts = b2Array_Create(); - island.joints = b2Array_Create(); + b2Array_Clear(ref island.bodies); + b2Array_Clear(ref island.contacts); + b2Array_Clear(ref island.joints); island.constraintRemoveCount = 0; ref B2IslandSim islandSim = ref b2Array_Emplace(ref set.islandSims); @@ -70,23 +70,18 @@ public static void b2DestroyIsland(B2World world, int islandId) B2SolverSet set = b2Array_Get(ref world.solverSets, island.setIndex); { int localIndex = island.localIndex; - int lastIndex = set.islandSims.count - 1; - B2_ASSERT(0 <= localIndex && localIndex <= lastIndex); - int moveIslandId = set.islandSims.data[lastIndex].islandId; - set.islandSims.data[localIndex].CopyFrom(set.islandSims.data[lastIndex]); - world.islands.data[moveIslandId].localIndex = localIndex; - if (localIndex != lastIndex) + int movedIndex = b2Array_RemoveSwap(ref set.islandSims, localIndex); + if (movedIndex != B2_NULL_INDEX) { - set.islandSims.data[lastIndex] = new B2IslandSim(); + int movedIslandId = set.islandSims.data[localIndex].islandId; + world.islands.data[movedIslandId].localIndex = localIndex; } - - set.islandSims.count -= 1; } // Free island and id (preserve island revision) - b2Array_Destroy(ref island.bodies); - b2Array_Destroy(ref island.contacts); - b2Array_Destroy(ref island.joints); + b2Array_Clear(ref island.bodies); + b2Array_Clear(ref island.contacts); + b2Array_Clear(ref island.joints); island.constraintRemoveCount = 0; island.localIndex = B2_NULL_INDEX; island.islandId = B2_NULL_INDEX; diff --git a/src/Box2D.NET/B2ParallelFor.cs b/src/Box2D.NET/B2ParallelFor.cs index 590443ca..f975f67c 100644 --- a/src/Box2D.NET/B2ParallelFor.cs +++ b/src/Box2D.NET/B2ParallelFor.cs @@ -47,7 +47,16 @@ private static void b2ParallelForTrampoline(object taskContext) end = itemCount; } - callback(start, end, workerIndex, context); + try + { + callback(start, end, workerIndex, context); + } + catch (Exception failure) + { + System.Threading.Interlocked.CompareExchange(ref shared.failure, failure, null); + b2AtomicStoreInt(ref shared.nextBlock, blockCount); + break; + } } } @@ -94,46 +103,81 @@ public static void b2ParallelFor(B2World world, b2ParallelForCallback callback, // No point enqueueing more tasks than blocks. int taskCount = b2MinInt(workerCount, blockCount); - B2ParallelForShared shared = new B2ParallelForShared + if (taskCount == 1) { - blockCount = blockCount, - blockSize = blockSize, - itemCount = itemCount, - callback = callback, - context = context, - }; - b2AtomicStoreInt(ref shared.nextBlock, 0); + callback(0, itemCount, 0, context); + return; + } - Span tasks = new B2ParallelForTask[B2_MAX_WORKERS]; - object[] handles = new object[B2_MAX_WORKERS]; - for (int i = 0; i < taskCount; ++i) + bool reuse = System.Threading.Interlocked.CompareExchange(ref world.parallelForInUse, 1, 0) == 0; + B2ParallelForShared shared = reuse + ? world.reusableParallelFor ?? (world.reusableParallelFor = new B2ParallelForShared()) + : new B2ParallelForShared(); + shared.blockCount = blockCount; + shared.blockSize = blockSize; + shared.itemCount = itemCount; + shared.callback = callback; + shared.context = context; + shared.failure = null; + b2AtomicStoreInt(ref shared.nextBlock, 0); + object[] handles = shared.handles; + int enqueuedCount = 0; + Exception workerFailure = null; + try { - tasks[i] = new B2ParallelForTask + for (int i = 0; i < taskCount; ++i) { - shared = shared, - workerIndex = i, - }; - - if (world.taskCount < B2_MAX_TASKS) - { - handles[i] = world.enqueueTaskFcn(b2ParallelForTrampoline, tasks[i], world.userTaskContext); - world.taskCount += 1; - world.activeTaskCount += handles[i] == null ? 0 : 1; - } - else - { - handles[i] = null; - b2ParallelForTrampoline(tasks[i]); + B2ParallelForTask task = shared.tasks[i]; + if (world.taskCount < B2_MAX_TASKS) + { + handles[i] = world.enqueueTaskFcn(b2ParallelForTrampoline, task, world.userTaskContext); + enqueuedCount = i + 1; + world.taskCount += 1; + world.activeTaskCount += handles[i] == null ? 0 : 1; + } + else + { + handles[i] = null; + b2ParallelForTrampoline(task); + } } } - - for (int i = 0; i < taskCount; ++i) + catch (Exception failure) { - if (handles[i] != null) + System.Threading.Interlocked.CompareExchange(ref shared.failure, failure, null); + b2AtomicStoreInt(ref shared.nextBlock, blockCount); + } + finally + { + for (int i = 0; i < enqueuedCount; ++i) { - world.finishTaskFcn(handles[i], world.userTaskContext); - world.activeTaskCount -= 1; + if (handles[i] != null) + { + try + { + world.finishTaskFcn(handles[i], world.userTaskContext); + } + catch (Exception failure) + { + System.Threading.Interlocked.CompareExchange(ref shared.failure, failure, null); + } + finally + { + world.activeTaskCount -= 1; + handles[i] = null; + } + } } + workerFailure = shared.failure; + shared.failure = null; + shared.callback = null; + shared.context = null; + if (reuse) + System.Threading.Volatile.Write(ref world.parallelForInUse, 0); + } + if (workerFailure != null) + { + throw new InvalidOperationException("Parallel worker failed.", workerFailure); } } } diff --git a/src/Box2D.NET/B2ParallelForShared.cs b/src/Box2D.NET/B2ParallelForShared.cs index 5b5734dd..9377eb4c 100644 --- a/src/Box2D.NET/B2ParallelForShared.cs +++ b/src/Box2D.NET/B2ParallelForShared.cs @@ -8,6 +8,18 @@ namespace Box2D.NET // claim work, so a slow chunk can't strand the other threads. internal class B2ParallelForShared { + public readonly B2ParallelForTask[] tasks = new B2ParallelForTask[B2Constants.B2_MAX_WORKERS]; + public readonly object[] handles = new object[B2Constants.B2_MAX_WORKERS]; + public System.Exception failure; + + public B2ParallelForShared() + { + for (int i = 0; i < tasks.Length; ++i) + { + tasks[i] = new B2ParallelForTask { shared = this, workerIndex = i }; + } + } + public B2AtomicInt nextBlock; public int blockCount; public int blockSize; diff --git a/src/Box2D.NET/B2RuntimeValidator.cs b/src/Box2D.NET/B2RuntimeValidator.cs index 82bcb49e..221d06e9 100644 --- a/src/Box2D.NET/B2RuntimeValidator.cs +++ b/src/Box2D.NET/B2RuntimeValidator.cs @@ -60,7 +60,7 @@ private void CheckSizeSeries() { ThrowIf(B2FixedArray8.Size == B2_MAX_POLYGON_VERTICES, $"B2FixedArray8 and {nameof(B2_MAX_POLYGON_VERTICES)} have the same size, which is unexpected."); ThrowIf(B2FixedArray8.Size == B2_MAX_POLYGON_VERTICES, $"B2FixedArray8 and {nameof(B2_MAX_POLYGON_VERTICES)} have the same size, which is unexpected."); - ThrowIf(B2FixedArray4.Size == B2_SIMD_WIDTH, $"B2FixedArray4 and {nameof(B2_SIMD_WIDTH)}have the same size, which is unexpected."); + ThrowIf(B2FixedArray8.Size == B2_SIMD_WIDTH, $"B2FixedArray8 and {nameof(B2_SIMD_WIDTH)}have the same size, which is unexpected."); ThrowIf(B2FixedArray8.Size == B2_MAX_CONTINUOUS_SENSOR_HITS, $"B2FixedArray8 and {nameof(B2_MAX_CONTINUOUS_SENSOR_HITS)}have the same size, which is unexpected."); } diff --git a/src/Box2D.NET/B2SolverSets.cs b/src/Box2D.NET/B2SolverSets.cs index c526c147..e1fa5582 100644 --- a/src/Box2D.NET/B2SolverSets.cs +++ b/src/Box2D.NET/B2SolverSets.cs @@ -27,16 +27,21 @@ public static B2SolverSet b2CreateSolverSet(B2World world) return set; } - public static void b2DestroySolverSet(B2World world, int setIndex) + public static void b2DestroySolverSet(B2World world, int setIndex) => b2DestroySolverSet(world, setIndex, false); + + internal static void b2DestroySolverSet(B2World world, int setIndex, bool releaseStorage) { B2SolverSet set = b2Array_Get(ref world.solverSets, setIndex); - b2Array_Destroy(ref set.bodySims); - b2Array_Destroy(ref set.bodyStates); - b2Array_Destroy(ref set.contactSims); - b2Array_Destroy(ref set.jointSims); - b2Array_Destroy(ref set.islandSims); - b2FreeId(world.solverSetIdPool, setIndex); - //*set = ( b2SolverSet ){ 0 }; + if (releaseStorage) + { + b2Array_Destroy(ref set.bodySims); + b2Array_Destroy(ref set.bodyStates); + b2Array_Destroy(ref set.contactSims); + b2Array_Destroy(ref set.jointSims); + b2Array_Destroy(ref set.islandSims); + } + if (set.setIndex != B2_NULL_INDEX) + b2FreeId(world.solverSetIdPool, setIndex); set.Clear(); set.setIndex = B2_NULL_INDEX; } @@ -205,9 +210,9 @@ internal static void b2TrySleepIsland(B2World world, int islandId) B2_ASSERT(0 <= island.localIndex && island.localIndex < awakeSet.islandSims.count); sleepSet.setIndex = sleepSetId; - sleepSet.bodySims = b2Array_Create(island.bodies.count); - sleepSet.contactSims = b2Array_Create(island.contacts.count); - sleepSet.jointSims = b2Array_Create(island.joints.count); + b2Array_Reserve(ref sleepSet.bodySims, island.bodies.count); + b2Array_Reserve(ref sleepSet.contactSims, island.contacts.count); + b2Array_Reserve(ref sleepSet.jointSims, island.joints.count); // move awake bodies to sleeping set // this shuffles around bodies in the awake set diff --git a/src/Box2D.NET/B2Solvers.cs b/src/Box2D.NET/B2Solvers.cs index 651d37a3..23d73001 100644 --- a/src/Box2D.NET/B2Solvers.cs +++ b/src/Box2D.NET/B2Solvers.cs @@ -996,6 +996,10 @@ internal static void b2ExecuteStage(B2SolverStage stage, B2StepContext context, // Execute a stage on worker 0 (main thread). internal static void b2ExecuteMainStage(B2SolverStage stage, B2StepContext context, uint syncBits) { + if (System.Threading.Volatile.Read(ref context.workerFailure) is Exception failure) + { + throw new InvalidOperationException("Constraint worker failed.", failure); + } int blockCount = stage.blockCount; if (blockCount == 0) { @@ -1004,6 +1008,14 @@ internal static void b2ExecuteMainStage(B2SolverStage stage, B2StepContext conte int workerIndex = 0; + if (context.workerCount == 1) + { + for (int i = 0; i < blockCount; i++) + b2ExecuteBlock(stage, context, stage.blocks[i].block, workerIndex); + return; + } + + if (blockCount == 1) { b2ExecuteBlock(stage, context, stage.blocks[0].block, workerIndex); @@ -1021,6 +1033,10 @@ internal static void b2ExecuteMainStage(B2SolverStage stage, B2StepContext conte // Spin waiting for thieves to finish while (b2AtomicLoadInt(ref stage.completionCount) != blockCount) { + if (System.Threading.Volatile.Read(ref context.workerFailure) is Exception workerFailure) + { + throw new InvalidOperationException("Constraint worker failed.", workerFailure); + } b2Pause(); } @@ -1030,6 +1046,20 @@ internal static void b2ExecuteMainStage(B2SolverStage stage, B2StepContext conte // Parallel solver task internal static void b2SolverTask(object taskContext) + { + B2StepContext context = ((B2WorkerContext)taskContext).context; + try + { + b2SolverTaskCore(taskContext); + } + catch (Exception failure) + { + System.Threading.Interlocked.CompareExchange(ref context.workerFailure, failure, null); + b2AtomicStoreU32(ref context.atomicSyncBits, uint.MaxValue); + } + } + + private static void b2SolverTaskCore(object taskContext) { B2WorkerContext workerContext = taskContext as B2WorkerContext; int workerIndex = workerContext.workerIndex; @@ -1410,7 +1440,7 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) B2BlockDim contactPrepareDim = b2ComputeBlockCount(wideContactCount, minContactsPerBlock, maxBlockCount); B2BlockDim jointPrepareDim = b2ComputeBlockCount(jointCount, minJointsPerBlock, maxBlockCount); - B2_ASSERT(B2FixedArray4.Size == B2_SIMD_WIDTH); + B2_ASSERT(B2FixedArray8.Size == B2_SIMD_WIDTH); int wideContactConstraintByteCount = b2GetWideContactConstraintByteCount(); ArraySegment wideContactConstraints = b2StackAlloc(world.stack, wideContactCount /** wideContactConstraintByteCount */, "contact constraint"); @@ -1424,8 +1454,10 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) // Build the span table for the flat prepare/store parallel-for while I slice the // wide constraint buffer across colors. One entry per active color plus a sentinel // at wideContactCount. - B2ContactPrepareSpan[] contactPrepareSpans = new B2ContactPrepareSpan[B2_GRAPH_COLOR_COUNT + 1]; - B2JointPrepareSpan[] jointPrepareSpans = new B2JointPrepareSpan[B2_GRAPH_COLOR_COUNT + 1]; + B2ContactPrepareSpan[] contactPrepareSpans = world.reusableContactPrepareSpans; + Array.Clear(contactPrepareSpans, 0, contactPrepareSpans.Length); + B2JointPrepareSpan[] jointPrepareSpans = world.reusableJointPrepareSpans; + Array.Clear(jointPrepareSpans, 0, jointPrepareSpans.Length); // Distribute transient constraints to each graph color and prepare spans { @@ -1539,7 +1571,7 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) // Prepare graph work blocks. Each color gets joint blocks followed by contact blocks. ArraySegment[] graphColorBlocks = world.reusableGraphColorBlocks; - Array.Clear(graphColorBlocks); + Array.Clear(graphColorBlocks, 0, graphColorBlocks.Length); ArraySegment baseGraphBlock = graphBlocks; B2FixedArray24 arrayGraphBlockCounts = new B2FixedArray24(); Span graphBlockCounts = arrayGraphBlockCounts.AsSpan(); @@ -1605,47 +1637,64 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) int jointIdCapacity = b2GetIdCapacity(world.jointIdPool); int contactIdCapacity = b2GetIdCapacity(world.contactIdPool); - for (int i = 0; i < workerCount; ++i) + try { - B2TaskContext taskContext = b2Array_Get(ref world.taskContexts, i); - b2SetBitCountAndClear(ref taskContext.jointStateBitSet, jointIdCapacity); - b2SetBitCountAndClear(ref taskContext.hitEventBitSet, contactIdCapacity); - taskContext.hasHitEvents = false; + for (int i = 0; i < workerCount; ++i) + { + B2TaskContext taskContext = b2Array_Get(ref world.taskContexts, i); + b2SetBitCountAndClear(ref taskContext.jointStateBitSet, jointIdCapacity); + b2SetBitCountAndClear(ref taskContext.hitEventBitSet, contactIdCapacity); + taskContext.hasHitEvents = false; - workerContext[i].context = stepContext; - workerContext[i].workerIndex = i; + workerContext[i].context = stepContext; + workerContext[i].workerIndex = i; - if (world.taskCount < B2_MAX_TASKS) - { - workerContext[i].userTask = world.enqueueTaskFcn(b2SolverTask, workerContext[i], world.userTaskContext); - world.taskCount += 1; - world.activeTaskCount += workerContext[i].userTask == null ? 0 : 1; - } - else - { - workerContext[i].userTask = null; - b2SolverTask(workerContext[i]); + if (world.taskCount < B2_MAX_TASKS) + { + workerContext[i].userTask = world.enqueueTaskFcn(b2SolverTask, workerContext[i], world.userTaskContext); + world.taskCount += 1; + world.activeTaskCount += workerContext[i].userTask == null ? 0 : 1; + } + else + { + workerContext[i].userTask = null; + b2SolverTask(workerContext[i]); + } } - } - // The calling thread of b2World_Step also enters b2SolverTask as worker 0 and races for the - // orchestrator slot via the CAS inside. This guarantees progress even when the user's task - // system can't run the queued worker 0 promptly: it might schedule out of order, have fewer - // threads than workerCount, or invert priority by parking the calling thread in finishTaskFcn. - // Whoever wins the CAS becomes the orchestrator; the loser returns and lets the spinner-only - // path handle workers >0. - B2WorkerContext callerContext = new B2WorkerContext(); - callerContext.context = stepContext; - callerContext.workerIndex = 0; - b2SolverTask(callerContext); + // The calling thread of b2World_Step also enters b2SolverTask as worker 0 and races for the + // orchestrator slot via the CAS inside. This guarantees progress even when the user's task + // system can't run the queued worker 0 promptly: it might schedule out of order, have fewer + // threads than workerCount, or invert priority by parking the calling thread in finishTaskFcn. + // Whoever wins the CAS becomes the orchestrator; the loser returns and lets the spinner-only + // path handle workers >0. + b2SolverTask(workerContext[0]); + + } + catch (Exception failure) + { + System.Threading.Interlocked.CompareExchange(ref stepContext.workerFailure, failure, null); + b2AtomicStoreU32(ref stepContext.atomicSyncBits, uint.MaxValue); + } // Finish constraint solve for (int i = 0; i < workerCount; ++i) { if (workerContext[i].userTask != null) { - world.finishTaskFcn(workerContext[i].userTask, world.userTaskContext); - world.activeTaskCount -= 1; + try + { + world.finishTaskFcn(workerContext[i].userTask, world.userTaskContext); + } + catch (Exception failure) + { + System.Threading.Interlocked.CompareExchange(ref stepContext.workerFailure, failure, null); + b2AtomicStoreU32(ref stepContext.atomicSyncBits, uint.MaxValue); + } + finally + { + world.activeTaskCount -= 1; + } } } @@ -1656,6 +1705,10 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) world.activeTaskCount -= 1; } + if (System.Threading.Volatile.Read(ref stepContext.workerFailure) is Exception workerFailure) + { + throw new InvalidOperationException("Constraint worker failed.", workerFailure); + } world.splitIslandId = B2_NULL_INDEX; world.profile.constraints = b2GetMillisecondsAndReset(ref constraintTicks); @@ -2023,7 +2076,7 @@ internal static void b2Solve(B2World world, B2StepContext stepContext) ulong sensorHitTicks = b2GetTicks(); int workerCount = world.workerCount; - B2_ASSERT(workerCount == world.taskContexts.count); + B2_ASSERT(workerCount <= world.taskContexts.count); for (int i = 0; i < workerCount; ++i) { diff --git a/src/Box2D.NET/B2StepContext.cs b/src/Box2D.NET/B2StepContext.cs index 398da795..ed7fb4a6 100644 --- a/src/Box2D.NET/B2StepContext.cs +++ b/src/Box2D.NET/B2StepContext.cs @@ -6,7 +6,7 @@ namespace Box2D.NET { - // Context for a time step. Recreated each time step. + // Per-world context reset before each time step. public class B2StepContext // TODO: @ikpil, check struct or class { internal void Reset() @@ -35,8 +35,11 @@ internal void Reset() enableWarmStarting = false; atomicSyncBits = default; mainClaimed = default; + workerFailure = null; } + internal Exception workerFailure; + // time step public float dt; diff --git a/src/Box2D.NET/B2World.cs b/src/Box2D.NET/B2World.cs index 4ef2d1ac..86a5fe47 100644 --- a/src/Box2D.NET/B2World.cs +++ b/src/Box2D.NET/B2World.cs @@ -13,6 +13,10 @@ public class B2World { internal readonly B2StepContext reusableStepContext = new B2StepContext(); internal readonly System.ArraySegment[] reusableGraphColorBlocks = new System.ArraySegment[B2Constants.B2_GRAPH_COLOR_COUNT]; + internal readonly B2ContactPrepareSpan[] reusableContactPrepareSpans = new B2ContactPrepareSpan[B2Constants.B2_GRAPH_COLOR_COUNT + 1]; + internal readonly B2JointPrepareSpan[] reusableJointPrepareSpans = new B2JointPrepareSpan[B2Constants.B2_GRAPH_COLOR_COUNT + 1]; + internal B2ParallelForShared reusableParallelFor; + internal int parallelForInUse; public B2StackAllocator stack; public B2BroadPhase broadPhase; public B2ConstraintGraph constraintGraph; @@ -137,7 +141,7 @@ public class B2World public b2FinishTaskCallback finishTaskFcn; public object userTaskContext; public object userTreeTask; - + public B2Scheduler scheduler; public B2UserData userData; @@ -167,6 +171,12 @@ public class B2World public void Clear() { + reusableStepContext.Reset(); + System.Array.Clear(reusableGraphColorBlocks, 0, reusableGraphColorBlocks.Length); + System.Array.Clear(reusableContactPrepareSpans, 0, reusableContactPrepareSpans.Length); + System.Array.Clear(reusableJointPrepareSpans, 0, reusableJointPrepareSpans.Length); + reusableParallelFor = null; + parallelForInUse = 0; stack = null; broadPhase = null; @@ -229,7 +239,7 @@ public void Clear() contactSpeed = 0.0f; contactHertz = 0.0f; contactDampingRatio = 0.0f; - contactRecycleDistance = 0.0f; + contactRecycleDistance = 0.0f; frictionCallback = null; restitutionCallback = null; diff --git a/src/Box2D.NET/B2Worlds.cs b/src/Box2D.NET/B2Worlds.cs index e3a3eb28..f1fe06c6 100644 --- a/src/Box2D.NET/B2Worlds.cs +++ b/src/Box2D.NET/B2Worlds.cs @@ -449,11 +449,7 @@ public static void b2DestroyWorld(B2WorldId worldId) int setCapacity = world.solverSets.count; for (int i = 0; i < setCapacity; ++i) { - B2SolverSet set = world.solverSets.data[i]; - if (set.setIndex != B2_NULL_INDEX) - { - b2DestroySolverSet(world, i); - } + b2DestroySolverSet(world, i, true); } b2Array_Destroy(ref world.solverSets); @@ -903,117 +899,135 @@ public static void b2World_Step(B2WorldId worldId, float timeStep, int subStepCo b2TracyCZoneNC(B2TracyCZone.world_step, "Step", B2HexColor.b2_colorBox2DGreen, true); world.locked = true; - world.activeTaskCount = 0; - world.taskCount = 0; - - if (world.scheduler != null) + try { - b2ResetScheduler(world.scheduler); - } + world.activeTaskCount = 0; + world.taskCount = 0; - ulong stepTicks = b2GetTicks(); + if (world.scheduler != null) + { + b2ResetScheduler(world.scheduler); + } - { - ref B2Capacity c = ref world.maxCapacity; - c.staticShapeCount = b2MaxInt(c.staticShapeCount, world.broadPhase.trees[(int)B2BodyType.b2_staticBody].proxyCount); - c.dynamicShapeCount = b2MaxInt(c.dynamicShapeCount, world.broadPhase.trees[(int)B2BodyType.b2_dynamicBody].proxyCount); + ulong stepTicks = b2GetTicks(); - int staticBodyCount = world.solverSets.data[(int)B2SolverSetType.b2_staticSet].bodySims.count; - c.staticBodyCount = b2MaxInt(c.staticBodyCount, staticBodyCount); + { + ref B2Capacity c = ref world.maxCapacity; + c.staticShapeCount = b2MaxInt(c.staticShapeCount, world.broadPhase.trees[(int)B2BodyType.b2_staticBody].proxyCount); + c.dynamicShapeCount = b2MaxInt(c.dynamicShapeCount, world.broadPhase.trees[(int)B2BodyType.b2_dynamicBody].proxyCount); - // this includes kinematic bodies - int totalBodyCount = b2GetIdCount(world.bodyIdPool); - c.dynamicBodyCount = b2MaxInt(c.dynamicBodyCount, totalBodyCount - staticBodyCount); + int staticBodyCount = world.solverSets.data[(int)B2SolverSetType.b2_staticSet].bodySims.count; + c.staticBodyCount = b2MaxInt(c.staticBodyCount, staticBodyCount); - int totalContactCount = b2GetIdCount(world.contactIdPool); - c.contactCount = b2MaxInt(c.contactCount, totalContactCount); - } + // this includes kinematic bodies + int totalBodyCount = b2GetIdCount(world.bodyIdPool); + c.dynamicBodyCount = b2MaxInt(c.dynamicBodyCount, totalBodyCount - staticBodyCount); - // Update collision pairs and create contacts - { - ulong pairTicks = b2GetTicks(); - b2UpdateBroadPhasePairs(world); - world.profile.pairs = b2GetMilliseconds(pairTicks); - } + int totalContactCount = b2GetIdCount(world.contactIdPool); + c.contactCount = b2MaxInt(c.contactCount, totalContactCount); + } - B2StepContext context = world.reusableStepContext; - context.Reset(); - context.world = world; - context.dt = timeStep; - context.subStepCount = b2MaxInt(1, subStepCount); + // Update collision pairs and create contacts + { + ulong pairTicks = b2GetTicks(); + b2UpdateBroadPhasePairs(world); + world.profile.pairs = b2GetMilliseconds(pairTicks); + } - if (timeStep > 0.0f) - { - context.inv_dt = 1.0f / timeStep; - context.h = timeStep / context.subStepCount; - context.inv_h = context.subStepCount * context.inv_dt; - } - else - { - context.inv_dt = 0.0f; - context.h = 0.0f; - context.inv_h = 0.0f; - } + B2StepContext context = world.reusableStepContext; + context.Reset(); + context.world = world; + context.dt = timeStep; + context.subStepCount = b2MaxInt(1, subStepCount); - world.inv_h = context.inv_h; - world.inv_dt = context.inv_dt; + if (timeStep > 0.0f) + { + context.inv_dt = 1.0f / timeStep; + context.h = timeStep / context.subStepCount; + context.inv_h = context.subStepCount * context.inv_dt; + } + else + { + context.inv_dt = 0.0f; + context.h = 0.0f; + context.inv_h = 0.0f; + } - // Hertz values get reduced for large time steps - float contactHertz = b2MinFloat(world.contactHertz, 0.125f * context.inv_h); - context.contactSoftness = b2MakeSoft(contactHertz, world.contactDampingRatio, context.h); - context.staticSoftness = b2MakeSoft(2.0f * contactHertz, world.contactDampingRatio, context.h); + world.inv_h = context.inv_h; + world.inv_dt = context.inv_dt; - context.restitutionThreshold = world.restitutionThreshold; - context.maxLinearVelocity = world.maxLinearSpeed; - context.enableWarmStarting = world.enableWarmStarting; + // Hertz values get reduced for large time steps + float contactHertz = b2MinFloat(world.contactHertz, 0.125f * context.inv_h); + context.contactSoftness = b2MakeSoft(contactHertz, world.contactDampingRatio, context.h); + context.staticSoftness = b2MakeSoft(2.0f * contactHertz, world.contactDampingRatio, context.h); - // Narrow phase : update contacts - { - ulong collideTicks = b2GetTicks(); - b2Collide(context); - world.profile.collide = b2GetMilliseconds(collideTicks); - } + context.restitutionThreshold = world.restitutionThreshold; + context.maxLinearVelocity = world.maxLinearSpeed; + context.enableWarmStarting = world.enableWarmStarting; - // Integrate velocities, solve velocity constraints, and integrate positions. - if (timeStep > 0.0f) - { - ulong solveTicks = b2GetTicks(); - b2Solve(world, context); - world.profile.solve = b2GetMilliseconds(solveTicks); - } + // Narrow phase : update contacts + { + ulong collideTicks = b2GetTicks(); + b2Collide(context); + world.profile.collide = b2GetMilliseconds(collideTicks); + } - // Finish the tree task in case b2Solve didn't finish it - if (world.userTreeTask != null) - { - world.finishTaskFcn(world.userTreeTask, world.userTaskContext); - world.userTreeTask = null; - world.activeTaskCount -= 1; - } + // Integrate velocities, solve velocity constraints, and integrate positions. + if (timeStep > 0.0f) + { + ulong solveTicks = b2GetTicks(); + b2Solve(world, context); + world.profile.solve = b2GetMilliseconds(solveTicks); + } - // Update sensors - { - ulong sensorTicks = b2GetTicks(); - b2OverlapSensors(world); - world.profile.sensors = b2GetMilliseconds(sensorTicks); - } + // Finish the tree task in case b2Solve didn't finish it + if (world.userTreeTask != null) + { + world.finishTaskFcn(world.userTreeTask, world.userTaskContext); + world.userTreeTask = null; + world.activeTaskCount -= 1; + } + + // Update sensors + { + ulong sensorTicks = b2GetTicks(); + b2OverlapSensors(world); + world.profile.sensors = b2GetMilliseconds(sensorTicks); + } - world.profile.step = b2GetMilliseconds(stepTicks); + world.profile.step = b2GetMilliseconds(stepTicks); - B2_ASSERT(b2GetStackAllocation(world.stack) == 0); + B2_ASSERT(b2GetStackAllocation(world.stack) == 0); - // Ensure stack is large enough - b2GrowStack(world.stack); + // Ensure stack is large enough + b2GrowStack(world.stack); - // Make sure all tasks that were started were also finished - B2_ASSERT(world.activeTaskCount == 0); + // Make sure all tasks that were started were also finished + B2_ASSERT(world.activeTaskCount == 0); - b2TracyCZoneEnd(B2TracyCZone.world_step); + b2TracyCZoneEnd(B2TracyCZone.world_step); - // Swap end event array buffers - world.endEventArrayIndex = 1 - world.endEventArrayIndex; - b2Array_Clear(ref world.sensorEndEvents[world.endEventArrayIndex]); - b2Array_Clear(ref world.contactEndEvents[world.endEventArrayIndex]); - world.locked = false; + // Swap end event array buffers + world.endEventArrayIndex = 1 - world.endEventArrayIndex; + b2Array_Clear(ref world.sensorEndEvents[world.endEventArrayIndex]); + b2Array_Clear(ref world.contactEndEvents[world.endEventArrayIndex]); + } + finally + { + try + { + if (world.userTreeTask != null) + { + world.finishTaskFcn(world.userTreeTask, world.userTaskContext); + world.userTreeTask = null; + world.activeTaskCount -= 1; + } + } + finally + { + world.locked = false; + } + } //b2TracyCFrame } @@ -1022,55 +1036,55 @@ internal static void b2DrawShape(B2DebugDraw draw, B2Shape shape, B2Transform xf switch (shape.type) { case B2ShapeType.b2_capsuleShape: - { - ref readonly B2Capsule capsule = ref shape.us.capsule; - B2Vec2 p1 = b2TransformPoint(xf, capsule.center1); - B2Vec2 p2 = b2TransformPoint(xf, capsule.center2); - draw.DrawSolidCapsuleFcn(p1, p2, capsule.radius, color, draw.context); - } + { + ref readonly B2Capsule capsule = ref shape.us.capsule; + B2Vec2 p1 = b2TransformPoint(xf, capsule.center1); + B2Vec2 p2 = b2TransformPoint(xf, capsule.center2); + draw.DrawSolidCapsuleFcn(p1, p2, capsule.radius, color, draw.context); + } break; case B2ShapeType.b2_circleShape: - { - ref readonly B2Circle circle = ref shape.us.circle; - xf.p = b2TransformPoint(xf, circle.center); - draw.DrawSolidCircleFcn(xf, circle.radius, color, draw.context); - } + { + ref readonly B2Circle circle = ref shape.us.circle; + xf.p = b2TransformPoint(xf, circle.center); + draw.DrawSolidCircleFcn(xf, circle.radius, color, draw.context); + } break; case B2ShapeType.b2_polygonShape: - { - ref B2Polygon poly = ref shape.us.polygon; - draw.DrawSolidPolygonFcn(xf, poly.vertices.AsSpan(), poly.count, poly.radius, color, draw.context); - } + { + ref B2Polygon poly = ref shape.us.polygon; + draw.DrawSolidPolygonFcn(xf, poly.vertices.AsSpan(), poly.count, poly.radius, color, draw.context); + } break; case B2ShapeType.b2_segmentShape: - { - ref readonly B2Segment segment = ref shape.us.segment; - B2Vec2 p1 = b2TransformPoint(xf, segment.point1); - B2Vec2 p2 = b2TransformPoint(xf, segment.point2); - draw.DrawLineFcn(p1, p2, color, draw.context); - } + { + ref readonly B2Segment segment = ref shape.us.segment; + B2Vec2 p1 = b2TransformPoint(xf, segment.point1); + B2Vec2 p2 = b2TransformPoint(xf, segment.point2); + draw.DrawLineFcn(p1, p2, color, draw.context); + } break; case B2ShapeType.b2_chainSegmentShape: - { - ref readonly B2Segment segment = ref shape.us.chainSegment.segment; - B2Vec2 p1 = b2TransformPoint(xf, segment.point1); - B2Vec2 p2 = b2TransformPoint(xf, segment.point2); - draw.DrawLineFcn(p1, p2, color, draw.context); - draw.DrawPointFcn(p2, 4.0f, color, draw.context); - - if (drawChainNormals) { - B2Vec2 c = b2Lerp(p1, p2, 0.5f); - B2Vec2 e = b2Normalize(b2Sub(p2, p1)); - B2Vec2 n = b2RightPerp(e); - float L = 0.2f * b2GetLengthUnitsPerMeter(); - draw.DrawLineFcn(c, b2MulAdd(c, L, n), B2HexColor.b2_colorPaleGreen, draw.context); + ref readonly B2Segment segment = ref shape.us.chainSegment.segment; + B2Vec2 p1 = b2TransformPoint(xf, segment.point1); + B2Vec2 p2 = b2TransformPoint(xf, segment.point2); + draw.DrawLineFcn(p1, p2, color, draw.context); + draw.DrawPointFcn(p2, 4.0f, color, draw.context); + + if (drawChainNormals) + { + B2Vec2 c = b2Lerp(p1, p2, 0.5f); + B2Vec2 e = b2Normalize(b2Sub(p2, p1)); + B2Vec2 n = b2RightPerp(e); + float L = 0.2f * b2GetLengthUnitsPerMeter(); + draw.DrawLineFcn(c, b2MulAdd(c, L, n), B2HexColor.b2_colorPaleGreen, draw.context); + } } - } break; default: @@ -3174,8 +3188,8 @@ internal static void b2ValidateSolverSets(B2World world) int jointIdCount = b2GetIdCount(world.jointIdPool); B2_ASSERT(totalJointCount == jointIdCount); -// Validate shapes -// This is very slow on compounds + // Validate shapes + // This is very slow on compounds #if FALSE int shapeCapacity = b2Array(world.shapeArray).count; for (int shapeIndex = 0; shapeIndex < shapeCapacity; shapeIndex += 1) diff --git a/src/Box2D.NET/Box2D.NET.csproj b/src/Box2D.NET/Box2D.NET.csproj index 77b9c436..eec82ff3 100644 --- a/src/Box2D.NET/Box2D.NET.csproj +++ b/src/Box2D.NET/Box2D.NET.csproj @@ -2,6 +2,8 @@ netstandard2.1;net8.0;net9.0;net10.0 + + 12.0 Box2D.NET README.md ikpil diff --git a/test/Box2D.NET.Test/B2StepReuseTests.cs b/test/Box2D.NET.Test/B2StepReuseTests.cs new file mode 100644 index 00000000..52d3c2d5 --- /dev/null +++ b/test/Box2D.NET.Test/B2StepReuseTests.cs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using static Box2D.NET.B2Arrays; +using static Box2D.NET.B2Bodies; +using static Box2D.NET.B2Joints; +using static Box2D.NET.B2ParallelFors; +using static Box2D.NET.B2Types; +using static Box2D.NET.B2Worlds; + +namespace Box2D.NET.Test; + +public class B2StepReuseTests +{ + [Test] + public void RemovedReferenceReturnsToSpareSlotWithoutAliasing() + { + var array = b2Array_Create(3); + var first = b2Array_Emplace(ref array); + first.angularVelocity = 17; + var second = b2Array_Emplace(ref array); + Assert.That(b2Array_RemoveSwap(ref array, 0), Is.EqualTo(1)); + Assert.That(array.data[0], Is.SameAs(second)); + Assert.That(b2Array_Emplace(ref array), Is.SameAs(first)); + Assert.That(array.data[0], Is.Not.SameAs(array.data[1])); + b2Array_Destroy(ref array); + } + + [Test] + public void SleepingSetRetainsCapacityAndWorldDestructionReleasesIt() + { + var worldId = b2CreateWorld(b2DefaultWorldDef()); + var world = b2GetWorldFromId(worldId); + B2SolverSet sleeping = null; + try + { + var definition = b2DefaultBodyDef(); + definition.type = B2BodyType.b2_dynamicBody; + var id = b2CreateBody(worldId, definition); + b2Body_SetAwake(id, false); + var body = b2GetBodyFullId(world, id); + sleeping = world.solverSets.data[body.setIndex]; + var storage = sleeping.bodySims.data; + for (int i = 0; i < 32; ++i) + { + b2Body_SetAwake(id, true); + Assert.That(sleeping.bodySims.count, Is.Zero); + Assert.That(sleeping.bodySims.data, Is.SameAs(storage)); + b2Body_SetAwake(id, false); + Assert.That(world.solverSets.data[body.setIndex], Is.SameAs(sleeping)); + Assert.That(sleeping.bodySims.data, Is.SameAs(storage)); + } + b2Body_SetAwake(id, true); + } + finally + { + b2DestroyWorld(worldId); + } + Assert.That(sleeping.bodySims.data, Is.Null); + Assert.That(sleeping.islandSims.data, Is.Null); + } + + [TestCase(false)] + [TestCase(true)] + public void StaticJointAndWarmedSerialStepAllocateNothing(bool wheel) + { + var worldId = b2CreateWorld(b2DefaultWorldDef()); + try + { + var ground = b2CreateBody(worldId, b2DefaultBodyDef()); + var definition = b2DefaultBodyDef(); + definition.type = B2BodyType.b2_dynamicBody; + definition.enableSleep = false; + var body = b2CreateBody(worldId, definition); + if (wheel) + { + var joint = b2DefaultWheelJointDef(); + joint.@base.bodyIdA = ground; + joint.@base.bodyIdB = body; + b2CreateWheelJoint(worldId, joint); + } + else + { + var joint = b2DefaultRevoluteJointDef(); + joint.@base.bodyIdA = ground; + joint.@base.bodyIdB = body; + b2CreateRevoluteJoint(worldId, joint); + } + // Warm the allocation counter, JIT and storage before measuring steady steps. + _ = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1600; ++i) + b2World_Step(worldId, 1f / 60, 4); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 64; ++i) + b2World_Step(worldId, 1f / 60, 4); + long bytes = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.That(bytes, Is.Zero); + } + finally { b2DestroyWorld(worldId); } + } + + [Test] + public void ParallelForJoinsFailuresAndCanReuseItsStorage() + { + var definition = b2DefaultWorldDef(); + definition.workerCount = 4; + var worldId = b2CreateWorld(definition); + var world = b2GetWorldFromId(worldId); + try + { + var visits = new int[257]; + var threads = new int[4]; + b2ParallelForCallback visit = (start, end, worker, _) => + { + int thread = Environment.CurrentManagedThreadId; + int previous = Interlocked.CompareExchange(ref threads[worker], thread, 0); + if (previous != 0 && previous != thread) + throw new Exception("Worker identity changed during a range."); + for (int i = start; i < end; ++i) + Interlocked.Increment(ref visits[i]); + }; + b2ParallelFor(world, visit, visits.Length, 7, null); + Assert.That(visits.All(count => count == 1), Is.True); + Assert.That(world.activeTaskCount, Is.Zero); + Assert.Throws(() => b2ParallelFor(world, (_, _, _, _) => throw new Exception("Expected callback failure"), 257, 7, null)); + Assert.That(world.activeTaskCount, Is.Zero); + // Drain further calls after scheduler slots are exhausted as well. + for (int run = 0; run < 20; ++run) + { + Array.Clear(visits); + Array.Clear(threads); + b2ParallelFor(world, visit, visits.Length, 7, null); + Assert.That(visits.All(count => count == 1), Is.True); + } + } + finally { b2DestroyWorld(worldId); } + } + + [Test] + public void NestedParallelForKeepsOuterBatchState() + { + var world = new B2World { workerCount = 2 }; + world.enqueueTaskFcn = (callback, context, _) => { callback(context); return null; }; + world.finishTaskFcn = (_, _) => { }; + int outer = 0, inner = 0; + b2ParallelFor(world, (start, end, _, _) => + { + outer += end - start; + if (start == 0) + { + b2ParallelFor(world, (a, b, _, _) => inner += b - a, 113, 7, null); + } + }, 257, 7, null); + Assert.That(outer, Is.EqualTo(257)); + Assert.That(inner, Is.EqualTo(113)); + } + + [Test] + public void FatalConstraintFailureWakesPeersAndJoinsEveryTask() + { + int started = 0, finished = 0; + var definition = b2DefaultWorldDef(); + definition.workerCount = 2; + definition.enqueueTask = (callback, context, _) => + { + // Inject a failure after constraints have been prepared, before workers enter the solver. + if (context is B2WorkerContext worker && worker.workerIndex == 0) + worker.context.states = null; + Interlocked.Increment(ref started); + return Task.Run(() => callback(context)); + }; + definition.finishTask = (handle, _) => + { + ((Task)handle).GetAwaiter().GetResult(); + Interlocked.Increment(ref finished); + }; + var worldId = b2CreateWorld(definition); + var world = b2GetWorldFromId(worldId); + try + { + var body = b2DefaultBodyDef(); + body.type = B2BodyType.b2_dynamicBody; + for (int i = 0; i < 128; ++i) + b2CreateBody(worldId, body); + var step = Task.Run(() => Assert.Throws(() => b2World_Step(worldId, 1f / 60, 4))); + Assert.That(step.Wait(TimeSpan.FromSeconds(5)), Is.True, "Constraint failure stranded a worker."); + Assert.That(finished, Is.EqualTo(started)); + Assert.That(world.activeTaskCount, Is.Zero); + Assert.That(world.locked, Is.False); + } + finally { b2DestroyWorld(worldId); } + } +} diff --git a/test/Box2D.NET.Test/B2WideArithmeticTests.cs b/test/Box2D.NET.Test/B2WideArithmeticTests.cs new file mode 100644 index 00000000..9c13d9fd --- /dev/null +++ b/test/Box2D.NET.Test/B2WideArithmeticTests.cs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT + +using static Box2D.NET.B2ContactSolvers; +using System; +using NUnit.Framework; + +namespace Box2D.NET.Test; + +public class B2WideArithmeticTests +{ + [Test] + public void UpperLanesParticipateInMasksAndKeepTheFourLaneConstructor() + { + var value = new B2FloatW(1, 2, 3, 4); + Assert.That(value.AsSpan().Length, Is.EqualTo(8)); + Assert.That(value.H, Is.Zero); + value = default; + value.H = 1; + Assert.That(b2AllZeroW(value), Is.False); + value[7] = 0; + Assert.That(b2AllZeroW(value), Is.True); + } + + [Test] + public void MatchesScalarArithmeticIncludingEdgeCases() + { + var random = new Random(712); + float[] edge = [0, -0f, 1, -1, float.Epsilon, -float.Epsilon, float.MaxValue, float.MinValue, float.PositiveInfinity, float.NegativeInfinity, float.NaN]; + for (var i = 0; i < 4096; i++) + { + var a = new B2FloatW(); + var b = a; + var c = a; + for (var lane = 0; lane < Box2D.NET.B2Cores.B2_SIMD_WIDTH; lane++) + { + a[lane] = i < edge.Length * edge.Length ? edge[i % edge.Length] : (float)(random.NextDouble() * 200 - 100); + b[lane] = i < edge.Length * edge.Length ? edge[i / edge.Length] : (float)(random.NextDouble() * 200 - 100); + c[lane] = i < edge.Length * edge.Length ? edge[(i + lane) % edge.Length] : (float)(random.NextDouble() * 200 - 100); + } + var add = b2AddW(a, b); + var sub = b2SubW(a, b); + var mul = b2MulW(a, b); + var madd = b2MulAddW(a, b, c); + var msub = b2MulSubW(a, b, c); + var min = b2MinW(a, b); + var max = b2MaxW(a, b); + var clamp = b2SymClampW(a, b); + var or = b2OrW(a, b); + var greater = b2GreaterThanW(a, b); + var equal = b2EqualsW(a, b); + var blend = b2BlendW(a, b, c); + var zero = true; + for (var lane = 0; lane < Box2D.NET.B2Cores.B2_SIMD_WIDTH; lane++) + { + Equal(add[lane], a[lane] + b[lane]); + Equal(sub[lane], a[lane] - b[lane]); + Equal(mul[lane], a[lane] * b[lane]); + Equal(madd[lane], a[lane] + b[lane] * c[lane]); + Equal(msub[lane], a[lane] - b[lane] * c[lane]); + Equal(min[lane], a[lane] <= b[lane] ? a[lane] : b[lane], true); + Equal(max[lane], a[lane] >= b[lane] ? a[lane] : b[lane], true); + Equal(clamp[lane], a[lane] < -b[lane] ? -b[lane] : a[lane] > b[lane] ? b[lane] : a[lane], true); + Equal(or[lane], a[lane] != 0 || b[lane] != 0 ? 1 : 0); + Equal(greater[lane], a[lane] > b[lane] ? 1 : 0); + Equal(equal[lane], a[lane] == b[lane] ? 1 : 0); + Equal(blend[lane], c[lane] != 0 ? b[lane] : a[lane], true); + zero &= a[lane] == 0; + } + if (b2AllZeroW(a) != zero) + throw new InvalidOperationException("SIMD zero reduction differs from scalar arithmetic."); + } + } + + private static void Equal(float actual, float expected, bool preserveBits = false) + { + if (BitConverter.SingleToInt32Bits(actual) == BitConverter.SingleToInt32Bits(expected) || + !preserveBits && float.IsNaN(actual) && float.IsNaN(expected)) + return; + throw new InvalidOperationException($"SIMD differs from scalar arithmetic: {actual:R} versus {expected:R}."); + } +} diff --git a/tools/Box2D.NET.Benchmark/DENSE_PERFORMANCE.md b/tools/Box2D.NET.Benchmark/DENSE_PERFORMANCE.md new file mode 100644 index 00000000..ec90bd39 --- /dev/null +++ b/tools/Box2D.NET.Benchmark/DENSE_PERFORMANCE.md @@ -0,0 +1,111 @@ +# Dense contact performance and patch inventory + +This change collects all portable library patches accumulated downstream since +Box2D.NET tag `3.1.654` (`5efc96def866edbb4e5a9368d84de5bf8c2dcaca`). It adapts them to +upstream `main` at `154bacd0fddc0e72e484d3061cdc76aa2d842994`, preserving the newer +body flags, kinematic scatter guards, island representation, scheduler and task API. +It includes the original buffer reuse change in PR #101 and both static-joint +changes from PR #102. + +## Complete inventory + +The downstream audit compared every imported C# file against its pinned source, +excluding visibility and compiler-only adaptations. The resulting portable changes +are covered here: + +| Accumulated patch | Current upstream implementation | +| --- | --- | +| Per-world step context and graph-color block array | `B2World`, `B2StepContext`, `B2Worlds`, `B2Solvers`; reset transient fields, clear retained references on destruction | +| Empty overflow-contact allocation avoidance | Early returns in warm start, solve and restitution in `B2ContactSolvers` | +| Static revolute and wheel identity state reuse | `B2RevoluteJoints`, `B2WheelJoints`; retain dynamic-body write guards | +| Swap-removal preserves distinct spare reference objects | `B2Arrays`; island removal uses the shared primitive | +| Contact and island creation uses prepared array slots | `B2Contacts`, `B2Islands`; current upstream island arrays also retain capacity | +| Sleep/wake solver-set capacity retention | `B2SolverSets`; world destruction releases active and dormant storage | +| Eight-lane contact arithmetic and storage | `B2FloatW`, `B2Cores`, `B2ContactConstraintWide`, `B2ContactSolvers`, `B2RuntimeValidator`; gather/scatter and layout checks change together | +| Absent second contact point avoids normal/friction work | Warm start and solve skip only when every second-point normal mass is zero | +| Serial stages execute directly | `B2Solvers`; avoids stealing synchronization when only one worker is active | +| Retained multiworker jobs and constraint failure signaling | Adapted to the existing upstream scheduler through `B2ParallelFor`, `B2ParallelForShared`, `B2World`, `B2StepContext`, `B2Solvers`; join handles before propagating errors | +| New upstream preparation/caller storage | Reuse contact/joint span arrays and worker-0 context, rather than introducing new per-step allocations while porting the earlier reuse patch | + +The downstream engine's visibility changes, nullable/XML warning directives and +`B2ArenaAllocatorIndexer` visibility are embedding adaptations, not library patches. +Its awake-body worker policy, scene callbacks, fixture tags, contact snapshots, +interpolation and object-pair identity changes require engine-owned types and remain +in that engine. This PR uses upstream's built-in scheduler rather than copying the +engine's task adapter. Upstream already provides `B2WorldDef.capacity` for expected +body/contact storage; no engine-specific capacity API is added here. + +## Numerical and API boundaries + +Arithmetic keeps separate multiply/add operations. Min/max, clamp, masks and blend +preserve scalar NaN and signed-zero selection. .NET 8+ uses `Vector256` and +its runtime fallback; `netstandard2.1` keeps eight scalar lanes without adding an +intrinsics dependency. Its project uses C# 12 for the scalar target as well, enabling +compiler caching of static method-group delegates; modern targets retain their SDK +language defaults. Existing `Vector` overloads are unchanged. + +`B2FloatW` becomes 32 bytes and wide constraint indices become eight elements. +On .NET 8+, `X/Y/Z/W` are ref-returning properties over vector storage; scalar +fallback retains fields. Direct reads/writes and the four-argument constructor +remain usable (upper lanes start at zero), but reflection/field ABI and wide +structure layout change: consumers of these public low-level types must rebuild. +The two-argument `b2DestroySolverSet` entry point remains available. + +Cached buffers retain their peak capacity until world destruction. New topology +or a nested parallel-for may allocate. A fatal callback/constraint error joins +started work and permits world destruction; restarting a partly solved world is +not a recovery contract. + +## Reproduce + +```sh +DOTNET_TieredCompilation=0 dotnet run --project tools/Box2D.NET.Benchmark -c Release -f net10.0 -- --dense 1 +DOTNET_TieredCompilation=0 dotnet run --project tools/Box2D.NET.Benchmark -c Release -f net10.0 -- --dense 4 +``` + +For the baseline, check out `154bacd0fddc0e72e484d3061cdc76aa2d842994` in a separate +checkout and copy only `DenseBenchmark.cs` and the `--dense` dispatch in `Program.Main` +there. The library sources remain unchanged for baseline measurements. + +The benchmark encloses 1,536 always-awake circles in a tank, uses 1/144-second steps +with four substeps, warms 1,600 steps and samples 1,024. Construction, checksums and +JSON serialization are outside the allocation bracket. It reports both owner and +all-thread managed allocation, final topology, stage profiles and the complete +position/rotation/velocity SHA-256. + +Linux x64, Ryzen 7 5700X, .NET 10.0.1, Release, `DOTNET_TieredCompilation=0`, three +fresh processes per row. Runs alternate upstream/PR serial/PR four-worker within +each trial. Values below are medians of trial means and trial p99 values, not +percentiles of pooled samples. Desktop load was uncontrolled. + +| Implementation | Workers | Mean ms | p99 ms | All-thread managed bytes per sampled run | +| --- | ---: | ---: | ---: | ---: | +| Upstream main `154bacd` | 1 | 6.905 | 7.612 | 2,090,040 | +| This change | 1 | 0.997 | 1.153 | 0 | +| This change, built-in scheduler | 4 | 0.465 | 0.594 | 0 | + +All nine runs have 5,877 potential and 3,813 colored/overflow contacts. The complete +state hash is identical: +`C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD`. +Raw trials are in [dense-5700x-net10.json](results/dense-5700x-net10.json). +This measures this kernel workload, not rendered FPS, other CPUs, or a native +Box2D comparison. The native/downstream pinned-version results use an older +algorithm and are intentionally not used as this PR's baseline. + +## Verification + +- Release library build: `netstandard2.1`, `net8.0`, `net9.0`, `net10.0`. +- Entire NUnit suite: 208/208 in Release on .NET 8 and .NET 10; 208/208 in Debug on .NET 10. +- Regression checks cover eight-lane edge arithmetic, constructor/indexer behavior, + spare-slot identity, sleep/wake retention and destruction, allocation-free warmed + static revolute/wheel steps, uneven/nested parallel ranges, callback failures and + fatal constraint peer cancellation/joining. +- AVX-disabled regression/determinism tests: 12/12. One AVX-disabled dense run + retains the same state hash and zero all-thread allocation (1.391 ms mean, + 1.615 ms p99); this is a local fallback check, not execution on another CPU. +- Scalar `netstandard2.1` assembly was directly loaded by a .NET 10 test harness + referencing the same regression/determinism test sources: 12/12 passed. +- Existing falling-hinge determinism expectations remain unchanged across worker + counts: sleep step 328, hash `0x26E08AEE`. +- Local execution does not establish ARM, browser or other operating-system results. + .NET 9 built successfully; its runtime is not installed on the measured host. diff --git a/tools/Box2D.NET.Benchmark/DenseBenchmark.cs b/tools/Box2D.NET.Benchmark/DenseBenchmark.cs new file mode 100644 index 00000000..a1ddd034 --- /dev/null +++ b/tools/Box2D.NET.Benchmark/DenseBenchmark.cs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT + +using System; +using System.Linq; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using Box2D.NET; +using static Box2D.NET.B2Bodies; +using static Box2D.NET.B2Geometries; +using static Box2D.NET.B2Shapes; +using static Box2D.NET.B2Types; +using static Box2D.NET.B2Worlds; + +namespace Box2D.NET.PhysicsBenchmark; + +public static class DenseBenchmark +{ + public static int Run(string[] args) + { + + const int count = 1536, warmup = 1600, samples = 1024; + var definition = b2DefaultWorldDef(); + definition.gravity = new(0, 9.8f); + definition.restitutionThreshold = 0; + var workers = args.Length <= 1 ? 1 : int.Parse(args[1]); + if (workers < 1 || workers > Box2D.NET.B2Constants.B2_MAX_WORKERS) + throw new ArgumentOutOfRangeException(nameof(workers)); + definition.workerCount = workers; + var world = b2CreateWorld(definition); + try + { + var ground = b2CreateBody(world, b2DefaultBodyDef()); + var surface = b2DefaultShapeDef(); + surface.material.friction = .25f; + surface.material.restitution = .35f; + foreach (var (x, y, hx, hy) in new (float, float, float, float)[] + { (5.76f, 6.38f, 5.4f, .09f), (.38f, 4.2f, .09f, 2.16f), (11.14f, 4.2f, .09f, 2.16f), (5.76f, 1.9f, 5.4f, .06f) }) + { + var polygon = b2MakeOffsetBox(hx, hy, new B2Vec2(x, y), new B2Rot(1, 0)); + b2CreatePolygonShape(ground, surface, polygon); + } + var bodies = new B2BodyId[count]; + var shape = b2DefaultShapeDef(); + shape.density = .2f / (MathF.PI * .06f * .06f); + shape.material.friction = .25f; + shape.material.restitution = .35f; + var circle = new B2Circle { radius = .06f }; + for (var i = 0; i < count; i++) + { + var body = b2DefaultBodyDef(); + body.type = B2BodyType.b2_dynamicBody; + body.position = new(.65f + i % 50 * .2f, 2.16f + i / 50 * .125f); + body.enableSleep = false; + body.linearVelocity = new((i % 7 - 3) * .03f, 0); + body.linearDamping = .05f; + body.angularDamping = .1f; + bodies[i] = b2CreateBody(world, body); + b2CreateCircleShape(bodies[i], shape, circle); + } + for (var i = 0; i < warmup; i++) + b2World_Step(world, 1f / 144, 4); + var times = new double[samples]; + double collide = 0, solve = 0, pairs = 0, prepare = 0, impulses = 0, relax = 0, transforms = 0; + var totalAllocated = GC.GetTotalAllocatedBytes(true); + var allocated = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < samples; i++) + { + var start = Stopwatch.GetTimestamp(); + b2World_Step(world, 1f / 144, 4); + times[i] = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + var p = b2World_GetProfile(world); + collide += p.collide; + solve += p.solve; + pairs += p.pairs; + prepare += p.prepareConstraints; + impulses += p.solveImpulses; + relax += p.relaxImpulses; + transforms += p.transforms; + } + var bytes = GC.GetAllocatedBytesForCurrentThread() - allocated; + var allThreadsBytes = GC.GetTotalAllocatedBytes(true) - totalAllocated; + var states = new float[count * 7]; + for (var i = 0; i < count; i++) + { + var t = b2Body_GetTransform(bodies[i]); + var v = b2Body_GetLinearVelocity(bodies[i]); + if (!float.IsFinite(t.p.X) || !float.IsFinite(t.p.Y) || t.p.Y > 6.4f || t.p.Y < 1.85f) + throw new Exception("Non-finite body transform."); + states[i * 7] = t.p.X; + states[i * 7 + 1] = t.p.Y; + states[i * 7 + 2] = t.q.c; + states[i * 7 + 3] = t.q.s; + states[i * 7 + 4] = v.X; + states[i * 7 + 5] = v.Y; + states[i * 7 + 6] = b2Body_GetAngularVelocity(bodies[i]); + } + var counters = b2World_GetCounters(world); + Console.WriteLine(JsonSerializer.Serialize(new + { + runtime = RuntimeInformation.FrameworkDescription, + hardwareSIMD = System.Runtime.Intrinsics.Vector256.IsHardwareAccelerated, + lanes = Box2D.NET.B2Cores.B2_SIMD_WIDTH, + bodies = count, + contacts = counters.contactCount, + touchingContacts = counters.colorCounts.AsSpan().ToArray().Sum(), + workers = b2GetWorldFromId(world).workerCount, + hz = 144, + substeps = 4, + warmup, + samples, + meanMS = times.Average(), + p95MS = times.Order().ElementAt((int)(samples * .95)), + p99MS = times.Order().ElementAt((int)(samples * .99)), + bytes, + allThreadsBytes, + stateSHA256 = Convert.ToHexString(SHA256.HashData(MemoryMarshal.AsBytes(states.AsSpan()))), + collideMS = collide / samples, + solveMS = solve / samples, + pairsMS = pairs / samples, + prepareMS = prepare / samples, + impulsesMS = impulses / samples, + relaxMS = relax / samples, + transformsMS = transforms / samples + })); + } + finally { b2DestroyWorld(world); } + + return 0; + } +} diff --git a/tools/Box2D.NET.Benchmark/Program.cs b/tools/Box2D.NET.Benchmark/Program.cs index c32f4862..0ff05154 100644 --- a/tools/Box2D.NET.Benchmark/Program.cs +++ b/tools/Box2D.NET.Benchmark/Program.cs @@ -39,6 +39,11 @@ public Benchmark(string name, CreateFcn createFcn, StepFcn stepFcn, int totalSte public static int Main(string[] args) { + if (args.Length > 0 && args[0] == "--dense") + { + return DenseBenchmark.Run(args); + } + Benchmark[] benchmarks = CreateBenchmarks(); int maxSteps = benchmarks[0].TotalStepCount; diff --git a/tools/Box2D.NET.Benchmark/README.md b/tools/Box2D.NET.Benchmark/README.md index 7fcb2373..432ec190 100644 --- a/tools/Box2D.NET.Benchmark/README.md +++ b/tools/Box2D.NET.Benchmark/README.md @@ -34,3 +34,7 @@ configurations, not expected values for managed runs on different hardware. Release builds use the upstream scenario sizes and step counts. Debug builds use the same reduced scenario sizes and ten-step loop as the native debug runner, which is useful for smoke checks. + +## Dense contact regression workload + +Use `--dense 1` or `--dense 4` to run the always-awake 1,536-circle allocation and timing workload. See [the patch inventory, reproduction commands and measured results](DENSE_PERFORMANCE.md). diff --git a/tools/Box2D.NET.Benchmark/results/dense-5700x-net10-noavx.json b/tools/Box2D.NET.Benchmark/results/dense-5700x-net10-noavx.json new file mode 100644 index 00000000..70fa627d --- /dev/null +++ b/tools/Box2D.NET.Benchmark/results/dense-5700x-net10-noavx.json @@ -0,0 +1 @@ +{"runtime":".NET 10.0.1","hardwareSIMD":false,"lanes":8,"bodies":1536,"contacts":5877,"touchingContacts":3813,"workers":1,"hz":144,"substeps":4,"warmup":1600,"samples":1024,"meanMS":1.3910411132812506,"p95MS":1.4796,"p99MS":1.6145,"bytes":0,"allThreadsBytes":0,"stateSHA256":"C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD","collideMS":0.3314395586203318,"solveMS":1.0591734966728836,"pairsMS":5.939745993188694E-05,"prepareMS":0.06705164154118393,"impulsesMS":0.23035251232795417,"relaxMS":0.3338750657858327,"transformsMS":0.05341820507237571} diff --git a/tools/Box2D.NET.Benchmark/results/dense-5700x-net10.json b/tools/Box2D.NET.Benchmark/results/dense-5700x-net10.json new file mode 100644 index 00000000..0c189e53 --- /dev/null +++ b/tools/Box2D.NET.Benchmark/results/dense-5700x-net10.json @@ -0,0 +1,254 @@ +[ + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 4, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 1, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 6.904751855468748, + "p95MS": 7.2577, + "p99MS": 7.6123, + "bytes": 2090040, + "allThreadsBytes": 2090040, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.3649329684267286, + "solveMS": 6.537968449760228, + "pairsMS": 0.00013130175778997, + "prepareMS": 0.0682137236071867, + "impulsesMS": 2.0612116928678006, + "relaxMS": 3.853178129065782, + "transformsMS": 0.060866399471706245, + "label": "upstream", + "trial": 1 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 8, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 1, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 1.0085295898437487, + "p95MS": 1.1307, + "p99MS": 1.2553, + "bytes": 0, + "allThreadsBytes": 0, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.3193675800575875, + "solveMS": 0.6886579819838516, + "pairsMS": 6.584081940985698e-05, + "prepareMS": 0.06497180565929739, + "impulsesMS": 0.13979765532712918, + "relaxMS": 0.16810742462985218, + "transformsMS": 0.053886710993538145, + "label": "pr", + "trial": 1 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 8, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 4, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 0.45970546875000035, + "p95MS": 0.5161, + "p99MS": 0.5626, + "bytes": 0, + "allThreadsBytes": 0, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.10976552937063389, + "solveMS": 0.34951079907477833, + "pairsMS": 4.700195281159836e-05, + "prepareMS": 0.026033552710941876, + "impulsesMS": 0.07930955375195481, + "relaxMS": 0.08562762017390924, + "transformsMS": 0.020747422855492914, + "label": "pr", + "trial": 1 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 4, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 1, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 6.8828000000000085, + "p95MS": 7.1142, + "p99MS": 7.3255, + "bytes": 2090040, + "allThreadsBytes": 2090040, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.3749190649832599, + "solveMS": 6.506177389994264, + "pairsMS": 9.094824190825079e-05, + "prepareMS": 0.0681340234077652, + "impulsesMS": 2.0502484880853444, + "relaxMS": 3.8354279731865972, + "transformsMS": 0.06022690531244734, + "label": "upstream", + "trial": 2 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 8, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 1, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 0.98613837890625, + "p95MS": 1.0309, + "p99MS": 1.0984, + "bytes": 0, + "allThreadsBytes": 0, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.3173694620782044, + "solveMS": 0.6683535897755064, + "pairsMS": 5.916210904999275e-05, + "prepareMS": 0.06419662589541986, + "impulsesMS": 0.13479194532555994, + "relaxMS": 0.16200593188114, + "transformsMS": 0.05306373825078481, + "label": "pr", + "trial": 2 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 8, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 4, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 0.4657325195312498, + "p95MS": 0.543, + "p99MS": 0.5939, + "bytes": 0, + "allThreadsBytes": 0, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.11068605662876507, + "solveMS": 0.3545983425283339, + "pairsMS": 4.941503807032177e-05, + "prepareMS": 0.02660941799149441, + "impulsesMS": 0.08006062198546715, + "relaxMS": 0.08684216496476438, + "transformsMS": 0.020940746102496632, + "label": "pr", + "trial": 2 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 4, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 1, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 6.9144252929687475, + "p95MS": 7.2483, + "p99MS": 7.6481, + "bytes": 2090040, + "allThreadsBytes": 2090040, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.3666966150631197, + "solveMS": 6.54589090263471, + "pairsMS": 0.00010025781256572941, + "prepareMS": 0.06905071979417698, + "impulsesMS": 2.0605685194022954, + "relaxMS": 3.855630569625646, + "transformsMS": 0.06142666018786258, + "label": "upstream", + "trial": 3 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 8, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 1, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 0.9970977539062511, + "p95MS": 1.0682, + "p99MS": 1.1528, + "bytes": 0, + "allThreadsBytes": 0, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.31954793058685027, + "solveMS": 0.6771129153203219, + "pairsMS": 6.354589840995573e-05, + "prepareMS": 0.06683735159094795, + "impulsesMS": 0.13528770800621714, + "relaxMS": 0.16422587889246643, + "transformsMS": 0.0541828467103187, + "label": "pr", + "trial": 3 + }, + { + "runtime": ".NET 10.0.1", + "hardwareSIMD": true, + "lanes": 8, + "bodies": 1536, + "contacts": 5877, + "touchingContacts": 3813, + "workers": 4, + "hz": 144, + "substeps": 4, + "warmup": 1600, + "samples": 1024, + "meanMS": 0.46476074218749935, + "p95MS": 0.5425, + "p99MS": 0.5997, + "bytes": 0, + "allThreadsBytes": 0, + "stateSHA256": "C2554173F6A5D78AD49163D4BBF0909906B31A92518A795A2FEC21A63E22CEDD", + "collideMS": 0.11099726273823762, + "solveMS": 0.35330267919925973, + "pairsMS": 5.0156249074007064e-05, + "prepareMS": 0.026399464841233566, + "impulsesMS": 0.07965948434866732, + "relaxMS": 0.08652463195176097, + "transformsMS": 0.02090815917836153, + "label": "pr", + "trial": 3 + } +]