perf(geometry): allocate entity IDs from a salted atomic sequence

This commit is contained in:
aj committed 2026-10-03 10:59:04 -04:00
1 parent 60d3915af2
commit 23d258b886
3 files changed
+116 -2

No files matched your search

+17 -2
View File
@@ -1,17 +1,32 @@
using System;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Drawing;
using System.Threading;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public abstract class Entity : IBoundable
{
private static long idCounter;
private static readonly Guid idSalt = Guid.NewGuid();
protected Box boundingBox;
protected Entity()
{
Id = Guid.NewGuid();
// Retain a process-random prefix; allocate the final six bytes atomically.
// IDs are opaque persistence keys, not unpredictable security tokens.
var counter = Interlocked.Increment(ref idCounter);
if ((ulong)counter > 0xFFFFFFFFFFFFUL)
throw new InvalidOperationException("Entity identifier sequence exhausted.");
Span<byte> bytes = stackalloc byte[16];
idSalt.TryWriteBytes(bytes);
Span<byte> counterBytes = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(counterBytes, counter);
counterBytes[..6].CopyTo(bytes[10..]);
Id = new Guid(bytes);
Layer = OpenNest.Geometry.Layer.Default;
boundingBox = new Box();
}
@@ -0,0 +1,91 @@
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Geometry;
public class EntityIdentityTests
{
[Fact]
public void ConstructionAndClone_UseDistinctNonemptyIdsAcrossEntityTypes()
{
var entities = CreateEntities();
var clones = entities.Select(entity => entity.Clone()).ToArray();
var ids = entities.Concat(clones).Select(entity => entity.Id).ToArray();
Assert.DoesNotContain(Guid.Empty, ids);
Assert.Equal(ids.Length, ids.Distinct().Count());
for (var i = 0; i < entities.Length; i++)
{
Assert.Equal(entities[i].Type, clones[i].Type);
Assert.Equal((entities[i].Left, entities[i].Right, entities[i].Bottom, entities[i].Top),
(clones[i].Left, clones[i].Right, clones[i].Bottom, clones[i].Top));
}
}
[Fact]
public void ConcurrentConstruction_IdsAreUniqueAcrossSharedProcessSequence()
{
var ids = new Guid[20_000];
Parallel.For(0, ids.Length, i => ids[i] = CreateEntities()[i % 5].Id);
Assert.DoesNotContain(Guid.Empty, ids);
Assert.Equal(ids.Length, ids.Distinct().Count());
}
[Fact]
public void GeneratedIds_ShareProcessSaltButNotCounterBytes()
{
var ids = CreateEntities().Select(entity => entity.Id.ToByteArray()).ToArray();
foreach (var id in ids.Skip(1))
Assert.Equal(ids[0][..10], id[..10]);
Assert.Equal(ids.Length, ids.Select(id => Convert.ToHexString(id[10..])).Distinct().Count());
}
[Fact]
public void ExplicitIdsAndSuppression_SurviveEntitySerialization()
{
var entities = CreateEntities().Take(3).ToList();
entities[0].Id = Guid.Parse("11111111-2222-3333-8444-555555555555");
entities[1].Id = Guid.Parse("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
var originalIds = entities.Select(entity => entity.Id).ToArray();
var suppressed = new HashSet<Guid> { entities[1].Id, entities[2].Id };
var dto = EntitySerializer.ToDto(entities, suppressed);
var (restored, restoredSuppressed) = EntitySerializer.FromDto(dto);
Assert.Equal(originalIds, restored.Select(entity => entity.Id));
Assert.True(suppressed.SetEquals(restoredSuppressed));
Assert.Equal(entities.Select(entity => entity.Type), restored.Select(entity => entity.Type));
Assert.NotEqual(restored[0].Id, restored[0].Clone().Id);
}
[Fact]
public void ExplicitIdsAndSuppression_SurviveNestFileRoundTrip()
{
var drawing = TestHelpers.MakeSquareDrawing();
drawing.SourceEntities = CreateEntities().Take(3).ToList();
drawing.SourceEntities[0].Id = Guid.Parse("11111111-2222-3333-8444-555555555555");
var ids = drawing.SourceEntities.Select(entity => entity.Id).ToArray();
drawing.SuppressedEntityIds.Add(ids[1]);
var nest = new Nest();
nest.Drawings.Add(drawing);
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
stream.Position = 0;
var restored = Assert.Single(new NestReader(stream).Read().Drawings);
Assert.Equal(ids, restored.SourceEntities.Select(entity => entity.Id));
Assert.Equal(ids[1], Assert.Single(restored.SuppressedEntityIds));
}
private static Entity[] CreateEntities() =>
[
new Line(0, 0, 2, 3),
new Arc(0, 0, 2, 0, System.Math.PI),
new Circle(new Vector(1, 2), 3),
new Polygon(),
new Shape(),
];
}
+8
View File
@@ -44,3 +44,11 @@ Predictor regression checks: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj -
Predictor availability uses the same one-attempt session initialization as inference. Publish completion only after assignment or definitive failure; concurrent callers must wait for the outcome. The builder skips extraction when unavailable and requests scalar-only features when available. Tests use isolated loaders/prediction doubles, not evidence of real ONNX inference.
Whole-job before/after comparisons use `OpenNest.Benchmark` with a `*.manifest.json` corpus and `--parallel 1`. Retain input hashes, exact commands, raw results, validity, fulfillment and cost for both revisions outside source control. Record baseline failures rather than treating them as regressions or tuning the corpus around them; overlapping timing ranges are inconclusive, not proof of unchanged performance.
## Entity identity and hot-test configuration
Use `-c Release` for routine cross-platform test runs, matching CI. Continue running counter-based regressions explicitly in Debug; do not treat fewer Release test cases as lost coverage without checking the DEBUG-only methods and individual skipped outcomes.
Entity IDs are opaque persistence keys, not security tokens or layout ordering keys. New entities use one process-random GUID prefix and an interlocked 48-bit sequence; clones receive fresh IDs. The sequence uses little-endian bytes independent of host byte order and throws on exhaustion rather than wrapping. Explicit restored/imported IDs remain authoritative, including suppressed source-entity IDs. `EntityIdentityTests` covers mixed entity types, concurrent creation, clone identity, serialization and the real `.nest` writer/reader.
For an identity-generation performance change, retain an untouched before tree, use identical harness bytes for both trees and compare full serialized `NestJobResult` SHA-256 hashes for deterministic, mixed curved/concave and zero-spacing jobs. Independently require zero layout violations. Verify each tree repeats its own hashes before using before/after equality as an oracle. If parallel output is unstable, use fresh processes with `DOTNET_PROCESSOR_COUNT=1`, verify a one-worker thread-pool cap and solve on that worker; this serial gate does not replace concurrent identity regressions. Measure solve time separately from validation and output serialization, interleave before/after processes and retain every batch. Re-profile the Release deterministic job to confirm per-entity `Guid.NewGuid` work is removed before claiming the measured gain.