streaming: bounded early admission - partial level sort, apply preflight, sorted transition index (L169, opt-in)
With StreamingFastTravelWaitForAllCriticalTiles=0 and ApplyBudget=1 ms (test values; defaults stay 1 and 0), 7 pairs
vs ca0bf76: near 43.3 -> 56.1%, forward 15.5 -> 17.9%, far holes 1.8 -> 0.0%, p99 7.06 -> 6.52 ms, worst 12.2 ->
11.8 ms, every run p99 <= 7.4 and worst <= 13.8 ms; arrival 1.671 -> 1.683 s (slower in 7/7) so not promoted.
Source/: VoxelWorld.cpp, VoxelWorld.h.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
This commit is contained in:
1 parent
ca0bf767ca
commit
786ecc4cdf
4 files changed
+664
-15
No files matched your search
@@ -7191,7 +7191,7 @@ void AVoxelWorld::LogReadyTransitionVisibilityMeasure()
|
|||||||
void AVoxelWorld::Tick(float DeltaTime)
|
void AVoxelWorld::Tick(float DeltaTime)
|
||||||
{
|
{
|
||||||
const bool bTraceFrameTiming = VoxelForgeStartupTrace::IsActive();
|
const bool bTraceFrameTiming = VoxelForgeStartupTrace::IsActive();
|
||||||
const uint64 TickStartCycles = bTraceFrameTiming ? FPlatformTime::Cycles64() : 0;
|
const uint64 TickStartCycles = FPlatformTime::Cycles64();
|
||||||
uint64 CollisionGateCycles = 0;
|
uint64 CollisionGateCycles = 0;
|
||||||
uint64 UpdateChunksCycles = 0;
|
uint64 UpdateChunksCycles = 0;
|
||||||
uint64 PendingDrainCycles = 0;
|
uint64 PendingDrainCycles = 0;
|
||||||
@@ -7418,7 +7418,7 @@ void AVoxelWorld::Tick(float DeltaTime)
|
|||||||
}
|
}
|
||||||
const uint64 PendingDrainStartCycles = bTraceFrameTiming
|
const uint64 PendingDrainStartCycles = bTraceFrameTiming
|
||||||
? FPlatformTime::Cycles64() : 0;
|
? FPlatformTime::Cycles64() : 0;
|
||||||
ProcessPendingChunks();
|
ProcessPendingChunks(TickStartCycles);
|
||||||
if (bTraceFrameTiming)
|
if (bTraceFrameTiming)
|
||||||
{
|
{
|
||||||
PendingDrainCycles += FPlatformTime::Cycles64() - PendingDrainStartCycles;
|
PendingDrainCycles += FPlatformTime::Cycles64() - PendingDrainStartCycles;
|
||||||
@@ -9899,7 +9899,7 @@ void AVoxelWorld::HandleTileCollisionCookComplete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void AVoxelWorld::ProcessPendingChunks()
|
void AVoxelWorld::ProcessPendingChunks(uint64 TickStartCycles)
|
||||||
{
|
{
|
||||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
||||||
// This runs on the game thread, called from Tick.
|
// This runs on the game thread, called from Tick.
|
||||||
@@ -10105,6 +10105,7 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
GetStreamingResultBacklogCount(), GVoxelForgeStreamingResultBacklogCap));
|
GetStreamingResultBacklogCount(), GVoxelForgeStreamingResultBacklogCap));
|
||||||
};
|
};
|
||||||
FChunkResult DequeuedChunk;
|
FChunkResult DequeuedChunk;
|
||||||
|
TArray<FChunkResult> DeferredApplyResults;
|
||||||
const auto DequeueNextResult = [&]() -> bool
|
const auto DequeueNextResult = [&]() -> bool
|
||||||
{
|
{
|
||||||
bDequeuedSupplementalFar = false;
|
bDequeuedSupplementalFar = false;
|
||||||
@@ -10234,8 +10235,6 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
const bool bRequestObsolete = DequeuedChunk.bObsolete
|
const bool bRequestObsolete = DequeuedChunk.bObsolete
|
||||||
|| (Cancellation.IsValid()
|
|| (Cancellation.IsValid()
|
||||||
&& Cancellation->bObsolete.load(std::memory_order_acquire));
|
&& Cancellation->bObsolete.load(std::memory_order_acquire));
|
||||||
PendingTileCancellation.Remove(DequeuedChunk.Tile);
|
|
||||||
PendingTiles.Remove(DequeuedChunk.Tile);
|
|
||||||
|
|
||||||
// A worker can observe shutdown after entering the mesher. Do not turn that partial
|
// A worker can observe shutdown after entering the mesher. Do not turn that partial
|
||||||
// result into an all-air tile: it must remain eligible for a future generation epoch.
|
// result into an all-air tile: it must remain eligible for a future generation epoch.
|
||||||
@@ -10244,6 +10243,8 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
// if the tile leaves and re-enters the desired set before the queue is drained.
|
// if the tile leaves and re-enters the desired set before the queue is drained.
|
||||||
if (DequeuedChunk.bAborted || bRequestObsolete)
|
if (DequeuedChunk.bAborted || bRequestObsolete)
|
||||||
{
|
{
|
||||||
|
PendingTileCancellation.Remove(DequeuedChunk.Tile);
|
||||||
|
PendingTiles.Remove(DequeuedChunk.Tile);
|
||||||
if (VoxelForgeStartupTrace::IsActive())
|
if (VoxelForgeStartupTrace::IsActive())
|
||||||
{
|
{
|
||||||
VoxelForgeStartupTrace::RecordEvent(TEXT("tile_discard"), FString::Printf(
|
VoxelForgeStartupTrace::RecordEvent(TEXT("tile_discard"), FString::Printf(
|
||||||
@@ -10264,6 +10265,55 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Estimate visible mesh submission cost before entering RMC's atomic CreateSectionGroup
|
||||||
|
// call. If this result would consume the remaining 11 ms tick target plus a 0.5 ms tail
|
||||||
|
// reserve, hold it for a later frame. The prediction is calibrated from L169 tile sizes:
|
||||||
|
// 2.2 ms fixed headroom + per-triangle and per-vertex work. Keep PendingTiles and its
|
||||||
|
// cancellation token alive while queued so the scheduler cannot submit a duplicate.
|
||||||
|
const bool bPreflightVisibleMesh = bFastTravelApplyBudgetActive
|
||||||
|
&& DequeuedChunk.Epoch == GenerationEpoch
|
||||||
|
&& IsDesired(DequeuedChunk.Tile)
|
||||||
|
&& !DequeuedChunk.bEmpty
|
||||||
|
&& DequeuedChunk.Streams.IsValid()
|
||||||
|
&& DequeuedChunk.NumTriangles > 0;
|
||||||
|
if (bPreflightVisibleMesh)
|
||||||
|
{
|
||||||
|
const uint64 PreflightCycles = FPlatformTime::Cycles64();
|
||||||
|
const double TickElapsedMilliseconds = TickStartCycles != 0
|
||||||
|
&& PreflightCycles >= TickStartCycles
|
||||||
|
? FPlatformTime::ToSeconds64(PreflightCycles - TickStartCycles) * 1000.0
|
||||||
|
: 0.0;
|
||||||
|
const double EstimatedApplyMilliseconds = 2.2
|
||||||
|
+ static_cast<double>(FMath::Max(0, DequeuedChunk.NumTriangles)) * 0.00012
|
||||||
|
+ static_cast<double>(FMath::Max(0, DequeuedChunk.NumVertices)) * 0.00003;
|
||||||
|
constexpr double TickApplyTargetMilliseconds = 11.0;
|
||||||
|
constexpr double TickApplyTailReserveMilliseconds = 0.5;
|
||||||
|
const double RemainingApplyMilliseconds = FMath::Max(
|
||||||
|
0.0, TickApplyTargetMilliseconds - TickApplyTailReserveMilliseconds
|
||||||
|
- TickElapsedMilliseconds);
|
||||||
|
if (EstimatedApplyMilliseconds > RemainingApplyMilliseconds)
|
||||||
|
{
|
||||||
|
if (VoxelForgeStartupTrace::IsActive())
|
||||||
|
{
|
||||||
|
VoxelForgeStartupTrace::RecordEvent(TEXT("tile_apply_deferred"), FString::Printf(
|
||||||
|
TEXT("\"frame\":%llu,\"tile\":[%d,%d,%d],\"level\":%d,"
|
||||||
|
"\"triangles\":%d,\"vertices\":%d,\"estimated_apply_ms\":%.3f,"
|
||||||
|
"\"elapsed_tick_ms\":%.3f,\"remaining_apply_ms\":%.3f"),
|
||||||
|
static_cast<unsigned long long>(GFrameCounter),
|
||||||
|
DequeuedChunk.Tile.Coord.X, DequeuedChunk.Tile.Coord.Y,
|
||||||
|
DequeuedChunk.Tile.Coord.Z, DequeuedChunk.Tile.Level,
|
||||||
|
DequeuedChunk.NumTriangles, DequeuedChunk.NumVertices,
|
||||||
|
EstimatedApplyMilliseconds, TickElapsedMilliseconds,
|
||||||
|
RemainingApplyMilliseconds));
|
||||||
|
}
|
||||||
|
DeferredApplyResults.Add(MoveTemp(DequeuedChunk));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingTileCancellation.Remove(DequeuedChunk.Tile);
|
||||||
|
PendingTiles.Remove(DequeuedChunk.Tile);
|
||||||
|
|
||||||
// ApplyTileResult does epoch check, capture ingest, empty-release / mesh submission, and
|
// ApplyTileResult does epoch check, capture ingest, empty-release / mesh submission, and
|
||||||
// only then records the tile as loaded. Only a real (visible) upload counts against the
|
// only then records the tile as loaded. Only a real (visible) upload counts against the
|
||||||
// per-frame budget — stale/empty drains are free.
|
// per-frame budget — stale/empty drains are free.
|
||||||
@@ -10323,6 +10373,11 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (FChunkResult& DeferredResult : DeferredApplyResults)
|
||||||
|
{
|
||||||
|
EnqueueStreamingResult(MoveTemp(DeferredResult));
|
||||||
|
}
|
||||||
|
|
||||||
if (bProfileApply && ResultsDrained > 0)
|
if (bProfileApply && ResultsDrained > 0)
|
||||||
{
|
{
|
||||||
UE_LOG(LogTemp, Display,
|
UE_LOG(LogTemp, Display,
|
||||||
@@ -10827,7 +10882,11 @@ void AVoxelWorld::HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile
|
|||||||
TArray<FVoxelTileKey, TInlineAllocator<32>> OverlappingDesired;
|
TArray<FVoxelTileKey, TInlineAllocator<32>> OverlappingDesired;
|
||||||
if (const TArray<FVoxelTileKey>* Descendants = DesiredTransitionDescendants.Find(RetiredTile))
|
if (const TArray<FVoxelTileKey>* Descendants = DesiredTransitionDescendants.Find(RetiredTile))
|
||||||
{
|
{
|
||||||
OverlappingDesired.Append(*Descendants);
|
OverlappingDesired.Reserve(OverlappingDesired.Num() + Descendants->Num());
|
||||||
|
for (const FVoxelTileKey& Descendant : *Descendants)
|
||||||
|
{
|
||||||
|
OverlappingDesired.Add(Descendant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (int32 Level = RetiredTile.Level + 1; Level <= DesiredTransitionMaxLevel; ++Level)
|
for (int32 Level = RetiredTile.Level + 1; Level <= DesiredTransitionMaxLevel; ++Level)
|
||||||
{
|
{
|
||||||
@@ -11064,9 +11123,18 @@ static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, i
|
|||||||
VoxelClipmapDesiredTiles::OuterShell(Parameters, R, MaxLevel, OutLevel, OutRadius);
|
VoxelClipmapDesiredTiles::OuterShell(Parameters, R, MaxLevel, OutLevel, OutRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool VF_TransitionIndexKeyLess(const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||||||
|
{
|
||||||
|
if (A.Level != B.Level) return A.Level < B.Level;
|
||||||
|
if (A.Coord.X != B.Coord.X) return A.Coord.X < B.Coord.X;
|
||||||
|
if (A.Coord.Y != B.Coord.Y) return A.Coord.Y < B.Coord.Y;
|
||||||
|
return A.Coord.Z < B.Coord.Z;
|
||||||
|
}
|
||||||
|
|
||||||
void AVoxelWorld::BuildDesiredTransitionIndex()
|
void AVoxelWorld::BuildDesiredTransitionIndex()
|
||||||
{
|
{
|
||||||
DesiredTransitionDescendants.Reset();
|
DesiredTransitionDescendants.Reset();
|
||||||
|
DesiredTransitionDescendants.Reserve(DesiredSorted.Num());
|
||||||
DesiredTransitionMaxLevel = 0;
|
DesiredTransitionMaxLevel = 0;
|
||||||
for (const FVoxelTileKey& Key : DesiredSorted)
|
for (const FVoxelTileKey& Key : DesiredSorted)
|
||||||
{
|
{
|
||||||
@@ -11079,7 +11147,9 @@ void AVoxelWorld::BuildDesiredTransitionIndex()
|
|||||||
constexpr int32 MaxSupportedTileLevel = 8;
|
constexpr int32 MaxSupportedTileLevel = 8;
|
||||||
const int32 DescendantIndexMaxLevel = FMath::Max(
|
const int32 DescendantIndexMaxLevel = FMath::Max(
|
||||||
DesiredTransitionMaxLevel, MaxSupportedTileLevel);
|
DesiredTransitionMaxLevel, MaxSupportedTileLevel);
|
||||||
for (const FVoxelTileKey& Key : DesiredSorted)
|
TArray<FVoxelTileKey> TransitionIndexBuildKeys = DesiredSorted;
|
||||||
|
TransitionIndexBuildKeys.Sort(VF_TransitionIndexKeyLess);
|
||||||
|
for (const FVoxelTileKey& Key : TransitionIndexBuildKeys)
|
||||||
{
|
{
|
||||||
for (int32 Level = Key.Level + 1; Level <= DescendantIndexMaxLevel; ++Level)
|
for (int32 Level = Key.Level + 1; Level <= DescendantIndexMaxLevel; ++Level)
|
||||||
{
|
{
|
||||||
@@ -11087,7 +11157,12 @@ void AVoxelWorld::BuildDesiredTransitionIndex()
|
|||||||
VF_AppendTouchingAncestors(Key, Level, TouchingAncestors);
|
VF_AppendTouchingAncestors(Key, Level, TouchingAncestors);
|
||||||
for (const FVoxelTileKey& Ancestor : TouchingAncestors)
|
for (const FVoxelTileKey& Ancestor : TouchingAncestors)
|
||||||
{
|
{
|
||||||
DesiredTransitionDescendants.FindOrAdd(Ancestor).Add(Key);
|
TArray<FVoxelTileKey>& Descendants =
|
||||||
|
DesiredTransitionDescendants.FindOrAdd(Ancestor);
|
||||||
|
if (Descendants.IsEmpty() || Descendants.Last() != Key)
|
||||||
|
{
|
||||||
|
Descendants.Add(Key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11187,6 +11262,10 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
double OverlayMaterializationMilliseconds = 0.0;
|
double OverlayMaterializationMilliseconds = 0.0;
|
||||||
double PrioritySortMilliseconds = 0.0;
|
double PrioritySortMilliseconds = 0.0;
|
||||||
double CriticalAdmissionOrderMilliseconds = 0.0;
|
double CriticalAdmissionOrderMilliseconds = 0.0;
|
||||||
|
double DesiredCollisionAndStampMilliseconds = 0.0;
|
||||||
|
double DesiredTransitionIndexMilliseconds = 0.0;
|
||||||
|
double DesiredRetainedRegionMilliseconds = 0.0;
|
||||||
|
double DesiredRegionReconcileMilliseconds = 0.0;
|
||||||
int32 IncrementalChangedLod4PlusKeyCount = 0;
|
int32 IncrementalChangedLod4PlusKeyCount = 0;
|
||||||
int32 IncrementalFallbackReason = 0;
|
int32 IncrementalFallbackReason = 0;
|
||||||
bool bCoreDesiredCacheHit = false;
|
bool bCoreDesiredCacheHit = false;
|
||||||
@@ -11954,6 +12033,8 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
(FPlatformTime::Seconds() - PrioritySortStartSeconds) * 1000.0;
|
(FPlatformTime::Seconds() - PrioritySortStartSeconds) * 1000.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const double DesiredCollisionAndStampStartSeconds = bTraceDesiredBreakdown
|
||||||
|
? FPlatformTime::Seconds() : 0.0;
|
||||||
RestoreKnownEmptyCollisionTiles();
|
RestoreKnownEmptyCollisionTiles();
|
||||||
ReconcileCollisionDesiredTiles();
|
ReconcileCollisionDesiredTiles();
|
||||||
for (auto It = DesiredStamped.CreateIterator(); It; ++It)
|
for (auto It = DesiredStamped.CreateIterator(); It; ++It)
|
||||||
@@ -11965,6 +12046,11 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CancelObsoleteTileWork();
|
CancelObsoleteTileWork();
|
||||||
|
if (DesiredCollisionAndStampStartSeconds != 0.0)
|
||||||
|
{
|
||||||
|
DesiredCollisionAndStampMilliseconds =
|
||||||
|
(FPlatformTime::Seconds() - DesiredCollisionAndStampStartSeconds) * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
const double CriticalAdmissionOrderStartSeconds = bTraceDesiredBreakdown
|
const double CriticalAdmissionOrderStartSeconds = bTraceDesiredBreakdown
|
||||||
? FPlatformTime::Seconds() : 0.0;
|
? FPlatformTime::Seconds() : 0.0;
|
||||||
@@ -12135,6 +12221,8 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
checkf(false, TEXT("VoxelForge incremental desired priority-order equality failure"));
|
checkf(false, TEXT("VoxelForge incremental desired priority-order equality failure"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const double DesiredTransitionIndexStartSeconds = bTraceDesiredBreakdown
|
||||||
|
? FPlatformTime::Seconds() : 0.0;
|
||||||
if (bIncrementalDesiredSet && bHadPreviousDesiredSet)
|
if (bIncrementalDesiredSet && bHadPreviousDesiredSet)
|
||||||
{
|
{
|
||||||
constexpr int32 MaxSupportedTileLevel = 8;
|
constexpr int32 MaxSupportedTileLevel = 8;
|
||||||
@@ -12143,6 +12231,27 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
const auto ApplyTransitionDelta = [this, TransitionIndexMaxLevel](
|
const auto ApplyTransitionDelta = [this, TransitionIndexMaxLevel](
|
||||||
const FVoxelTileKey& Key, bool bAdd)
|
const FVoxelTileKey& Key, bool bAdd)
|
||||||
{
|
{
|
||||||
|
const auto LowerBound = [](const TArray<FVoxelTileKey>& Descendants,
|
||||||
|
const FVoxelTileKey& SearchKey)
|
||||||
|
{
|
||||||
|
int32 First = 0;
|
||||||
|
int32 Count = Descendants.Num();
|
||||||
|
while (Count > 0)
|
||||||
|
{
|
||||||
|
const int32 Step = Count / 2;
|
||||||
|
const int32 Index = First + Step;
|
||||||
|
if (VF_TransitionIndexKeyLess(Descendants[Index], SearchKey))
|
||||||
|
{
|
||||||
|
First = Index + 1;
|
||||||
|
Count -= Step + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Count = Step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return First;
|
||||||
|
};
|
||||||
for (int32 Level = Key.Level + 1; Level <= TransitionIndexMaxLevel; ++Level)
|
for (int32 Level = Key.Level + 1; Level <= TransitionIndexMaxLevel; ++Level)
|
||||||
{
|
{
|
||||||
TArray<FVoxelTileKey, TInlineAllocator<8>> TouchingAncestors;
|
TArray<FVoxelTileKey, TInlineAllocator<8>> TouchingAncestors;
|
||||||
@@ -12153,12 +12262,21 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
{
|
{
|
||||||
TArray<FVoxelTileKey>& Descendants =
|
TArray<FVoxelTileKey>& Descendants =
|
||||||
DesiredTransitionDescendants.FindOrAdd(Ancestor);
|
DesiredTransitionDescendants.FindOrAdd(Ancestor);
|
||||||
if (!Descendants.Contains(Key)) Descendants.Add(Key);
|
const int32 InsertIndex = LowerBound(Descendants, Key);
|
||||||
|
if (InsertIndex == Descendants.Num() || Descendants[InsertIndex] != Key)
|
||||||
|
{
|
||||||
|
Descendants.Insert(Key, InsertIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (TArray<FVoxelTileKey>* Descendants =
|
else if (TArray<FVoxelTileKey>* Descendants =
|
||||||
DesiredTransitionDescendants.Find(Ancestor))
|
DesiredTransitionDescendants.Find(Ancestor))
|
||||||
{
|
{
|
||||||
Descendants->RemoveSingleSwap(Key, EAllowShrinking::No);
|
const int32 RemoveIndex = LowerBound(*Descendants, Key);
|
||||||
|
if (RemoveIndex < Descendants->Num()
|
||||||
|
&& (*Descendants)[RemoveIndex] == Key)
|
||||||
|
{
|
||||||
|
Descendants->RemoveAt(RemoveIndex, 1, EAllowShrinking::No);
|
||||||
|
}
|
||||||
if (Descendants->IsEmpty()) DesiredTransitionDescendants.Remove(Ancestor);
|
if (Descendants->IsEmpty()) DesiredTransitionDescendants.Remove(Ancestor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12171,7 +12289,14 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
{
|
{
|
||||||
BuildDesiredTransitionIndex();
|
BuildDesiredTransitionIndex();
|
||||||
}
|
}
|
||||||
|
if (DesiredTransitionIndexStartSeconds != 0.0)
|
||||||
|
{
|
||||||
|
DesiredTransitionIndexMilliseconds =
|
||||||
|
(FPlatformTime::Seconds() - DesiredTransitionIndexStartSeconds) * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double DesiredRetainedRegionStartSeconds = bTraceDesiredBreakdown
|
||||||
|
? FPlatformTime::Seconds() : 0.0;
|
||||||
bool bRetainedSetChanged = false;
|
bool bRetainedSetChanged = false;
|
||||||
int32 RetainedTileCount = 0;
|
int32 RetainedTileCount = 0;
|
||||||
TArray<FVoxelTileKey> MissingRetainedFineTiles;
|
TArray<FVoxelTileKey> MissingRetainedFineTiles;
|
||||||
@@ -12190,6 +12315,13 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (DesiredRetainedRegionStartSeconds != 0.0)
|
||||||
|
{
|
||||||
|
DesiredRetainedRegionMilliseconds =
|
||||||
|
(FPlatformTime::Seconds() - DesiredRetainedRegionStartSeconds) * 1000.0;
|
||||||
|
}
|
||||||
|
const double DesiredRegionReconcileStartSeconds = bTraceDesiredBreakdown
|
||||||
|
? FPlatformTime::Seconds() : 0.0;
|
||||||
TArray<FVoxelTileKey> MissingRegionTiles;
|
TArray<FVoxelTileKey> MissingRegionTiles;
|
||||||
{
|
{
|
||||||
FReadScopeLock Lock(State->WorldLock);
|
FReadScopeLock Lock(State->WorldLock);
|
||||||
@@ -12249,6 +12381,11 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
}
|
}
|
||||||
StartPendingCoreRegionCompile();
|
StartPendingCoreRegionCompile();
|
||||||
}
|
}
|
||||||
|
if (DesiredRegionReconcileStartSeconds != 0.0)
|
||||||
|
{
|
||||||
|
DesiredRegionReconcileMilliseconds =
|
||||||
|
(FPlatformTime::Seconds() - DesiredRegionReconcileStartSeconds) * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
if (bTraceDesiredBreakdown)
|
if (bTraceDesiredBreakdown)
|
||||||
{
|
{
|
||||||
@@ -12258,7 +12395,10 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
TEXT("\"frame\":%llu,\"center\":[%d,%d,%d],\"total_ms\":%s,"
|
TEXT("\"frame\":%llu,\"center\":[%d,%d,%d],\"total_ms\":%s,"
|
||||||
"\"core_prepare_ms\":%s,\"core_delta_ms\":%s,\"fallback_ms\":%s,"
|
"\"core_prepare_ms\":%s,\"core_delta_ms\":%s,\"fallback_ms\":%s,"
|
||||||
"\"overlay_materialize_ms\":%s,\"priority_sort_ms\":%s,"
|
"\"overlay_materialize_ms\":%s,\"priority_sort_ms\":%s,"
|
||||||
"\"critical_order_ms\":%s,\"core_keys\":%d,\"candidate_keys\":%d,"
|
"\"critical_order_ms\":%s,\"collision_stamp_ms\":%s,"
|
||||||
|
"\"transition_index_ms\":%s,\"retained_region_ms\":%s,"
|
||||||
|
"\"region_reconcile_ms\":%s,"
|
||||||
|
"\"core_keys\":%d,\"candidate_keys\":%d,"
|
||||||
"\"overlay_keys\":%d,\"critical_keys\":%d,\"fallback_reason\":%d,"
|
"\"overlay_keys\":%d,\"critical_keys\":%d,\"fallback_reason\":%d,"
|
||||||
"\"changed_lod4plus\":%d,\"materialization_update\":%d,"
|
"\"changed_lod4plus\":%d,\"materialization_update\":%d,"
|
||||||
"\"priority_order_reused\":%d,\"priority_order_updated\":%d,"
|
"\"priority_order_reused\":%d,\"priority_order_updated\":%d,"
|
||||||
@@ -12271,6 +12411,10 @@ void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector&
|
|||||||
*FString::SanitizeFloat(static_cast<float>(OverlayMaterializationMilliseconds)),
|
*FString::SanitizeFloat(static_cast<float>(OverlayMaterializationMilliseconds)),
|
||||||
*FString::SanitizeFloat(static_cast<float>(PrioritySortMilliseconds)),
|
*FString::SanitizeFloat(static_cast<float>(PrioritySortMilliseconds)),
|
||||||
*FString::SanitizeFloat(static_cast<float>(CriticalAdmissionOrderMilliseconds)),
|
*FString::SanitizeFloat(static_cast<float>(CriticalAdmissionOrderMilliseconds)),
|
||||||
|
*FString::SanitizeFloat(static_cast<float>(DesiredCollisionAndStampMilliseconds)),
|
||||||
|
*FString::SanitizeFloat(static_cast<float>(DesiredTransitionIndexMilliseconds)),
|
||||||
|
*FString::SanitizeFloat(static_cast<float>(DesiredRetainedRegionMilliseconds)),
|
||||||
|
*FString::SanitizeFloat(static_cast<float>(DesiredRegionReconcileMilliseconds)),
|
||||||
CoreDesiredTiles ? CoreDesiredTiles->Num()
|
CoreDesiredTiles ? CoreDesiredTiles->Num()
|
||||||
: static_cast<int32>(UncachedCoreDesiredTiles.size()),
|
: static_cast<int32>(UncachedCoreDesiredTiles.size()),
|
||||||
CandidateKeys.Num(), OverlayDesiredTiles.Num(), CriticalDesiredTiles.Num(),
|
CandidateKeys.Num(), OverlayDesiredTiles.Num(), CriticalDesiredTiles.Num(),
|
||||||
@@ -14118,14 +14262,17 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw
|
|||||||
{
|
{
|
||||||
const uint64 TraceLevelSortStartCycles = bTraceStreamingScheduler
|
const uint64 TraceLevelSortStartCycles = bTraceStreamingScheduler
|
||||||
? FPlatformTime::Cycles64() : 0;
|
? FPlatformTime::Cycles64() : 0;
|
||||||
|
const int32 LevelSubmissionCount = FMath::Min(
|
||||||
|
LevelTiles.Num(), FMath::Max(
|
||||||
|
0, AdmissionCap - GetGenerationAdmissionCount()));
|
||||||
const bool bUseForwardFirstOrder =
|
const bool bUseForwardFirstOrder =
|
||||||
GVoxelForgeStreamingFastTravelForwardFirst != 0
|
GVoxelForgeStreamingFastTravelForwardFirst != 0
|
||||||
&& bPolicy3FastTravel && Level <= 2;
|
&& bPolicy3FastTravel && Level <= 2;
|
||||||
if (bUseForwardFirstOrder)
|
if (LevelSubmissionCount > 0 && bUseForwardFirstOrder)
|
||||||
{
|
{
|
||||||
SortNearTilesForwardFirst(LevelTiles);
|
SortNearTilesForwardFirst(LevelTiles);
|
||||||
}
|
}
|
||||||
else
|
else if (LevelSubmissionCount > 0)
|
||||||
{
|
{
|
||||||
FVector Heading = GetActorTransform().InverseTransformVectorNoScale(PlayerHeading);
|
FVector Heading = GetActorTransform().InverseTransformVectorNoScale(PlayerHeading);
|
||||||
if (Heading.IsNearlyZero() && IsValid(PlayerPawn))
|
if (Heading.IsNearlyZero() && IsValid(PlayerPawn))
|
||||||
@@ -14138,7 +14285,12 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw
|
|||||||
const double TileSizeCm = static_cast<double>(VF_ClipLevelExtentVoxels(Level))
|
const double TileSizeCm = static_cast<double>(VF_ClipLevelExtentVoxels(Level))
|
||||||
* GetActiveVoxelSizeCm();
|
* GetActiveVoxelSizeCm();
|
||||||
const bool bUseFastNearLeadOrder = bPolicy3FastTravel && Level <= 2;
|
const bool bUseFastNearLeadOrder = bPolicy3FastTravel && Level <= 2;
|
||||||
std::stable_sort(LevelTiles.GetData(), LevelTiles.GetData() + LevelTiles.Num(),
|
// The full order is deterministic because the comparator breaks every tie
|
||||||
|
// with the tile coordinates. Select only the prefix this admission pass can
|
||||||
|
// submit; sorting every outstanding key made a small worker cap pay O(N log N).
|
||||||
|
std::partial_sort(LevelTiles.GetData(),
|
||||||
|
LevelTiles.GetData() + LevelSubmissionCount,
|
||||||
|
LevelTiles.GetData() + LevelTiles.Num(),
|
||||||
[&](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
[&](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||||||
{
|
{
|
||||||
const FVector CenterA(
|
const FVector CenterA(
|
||||||
@@ -14193,6 +14345,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw
|
|||||||
if (A.Coord.Y != B.Coord.Y) return A.Coord.Y < B.Coord.Y;
|
if (A.Coord.Y != B.Coord.Y) return A.Coord.Y < B.Coord.Y;
|
||||||
return A.Coord.Z < B.Coord.Z;
|
return A.Coord.Z < B.Coord.Z;
|
||||||
});
|
});
|
||||||
|
LevelTiles.SetNum(LevelSubmissionCount, EAllowShrinking::No);
|
||||||
}
|
}
|
||||||
if (bTraceStreamingScheduler)
|
if (bTraceStreamingScheduler)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1617,7 +1617,7 @@ public:
|
|||||||
// ONE frame → a game-thread teardown spike. Drained by ProcessUnloadQueue (catch-up scaled).
|
// ONE frame → a game-thread teardown spike. Drained by ProcessUnloadQueue (catch-up scaled).
|
||||||
TSet<FVoxelTileKey> PendingUnload;
|
TSet<FVoxelTileKey> PendingUnload;
|
||||||
|
|
||||||
void ProcessPendingChunks();
|
void ProcessPendingChunks(uint64 TickStartCycles);
|
||||||
void ProcessUnloadQueue(); // budgeted teardown drain (see PendingUnload)
|
void ProcessUnloadQueue(); // budgeted teardown drain (see PendingUnload)
|
||||||
void QueuePendingUnload(const FVoxelTileKey& Tile);
|
void QueuePendingUnload(const FVoxelTileKey& Tile);
|
||||||
void HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile);
|
void HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# L169 — Early admission stays opt-in
|
||||||
|
|
||||||
|
**Result:** Do not promote L157 early admission. Six of seven promotion gates passed, but candidate LOD0–2 arrival was slower in every pair: median **1.683 s** versus **1.671 s** on `assembler/world` `ca0bf76` (+12.4 ms). The source defaults are restored to wait-for-critical **1** and apply-budget **0 ms**. The candidate settings remain explicit overrides in the runner.
|
||||||
|
|
||||||
|
## Measurement
|
||||||
|
|
||||||
|
Seven interleaved W8 pairs used the four-strata owner plan, 25 cm core voxels, 30 m/s movement, a 12-second move from t=6 to t=18, and a 5-second dwell. Each run had about 46 moving samples; one warm-up per binary was excluded. No product CVar was changed except the two L157 switches under test. At measurement time, the candidate binary compiled those settings as defaults; the runner now supplies the same values explicitly so the experiment can be repeated with shipping defaults off.
|
||||||
|
|
||||||
|
| Moving-window metric (median across seven runs) | `ca0bf76` | Candidate | Result |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Tick p99 | 7.060 ms | 6.519 ms | lower |
|
||||||
|
| Worst tick | 12.219 ms | 11.776 ms | lower |
|
||||||
|
| Near LOD0–2 readiness | 43.28% | 56.12% | +12.84 pp |
|
||||||
|
| Forward-cone readiness | 15.52% | 17.93% | +2.41 pp |
|
||||||
|
| Far holes | 1.80% | 0.00% | lower |
|
||||||
|
| First settled LOD0–2 | 1.671 s | 1.683 s | **12.4 ms slower** |
|
||||||
|
|
||||||
|
Every run stayed below 11 ms p99 and 14 ms worst tick. The largest observed p99 was 7.400 ms; the largest worst tick was 13.776 ms. Candidate worst-tick median was not worse. Arrival remained slower after subtracting each run’s first streaming sample (1.305 s vs 1.299 s), so the raw arrival failure is not explained entirely by trace start time.
|
||||||
|
|
||||||
|
## Spike count and changes
|
||||||
|
|
||||||
|
All counts below use the 12-second moving window unless identified as a startup diagnostic.
|
||||||
|
|
||||||
|
- **Level sorting:** With L157 on at the base defaults, the largest full-sort sample was 0.865 ms and no sample exceeded 1 ms; the L167 8.3 ms spike did not recur. Candidate partial sorting reduced the largest sample to 0.123 ms, with no sample above 1 ms.
|
||||||
|
- **Desired rebuild and overlay:** Base per-run desired-rebuild peaks had a 7.476 ms median and reached 8.110 ms. Candidate peaks had a 6.928 ms median and reached 7.260 ms; rebuilds over 5 ms still occurred (median four per run, maximum five). Overlay materialization never exceeded 2 ms; its candidate maximum was 1.094 ms. The transition descendant index now uses sorted arrays and binary-search deltas. In a separate pre-move startup trace with 2,233 Core keys, the initial index build took 1.605 ms; the maximum moving-window index phase was 3.626 ms.
|
||||||
|
- **Mesh apply:** The 2.8 ms historical apply spike did not recur. Maximum per-frame apply time fell from 2.548 ms at base to 1.590 ms candidate; largest individual candidate apply was 1.512 ms. The preflight deferred 21–145 results per run (median 109), with a largest estimate of 3.447 ms for 8,979 triangles. RealtimeMesh submission remains atomic, so the estimate is a heuristic and does not split a single upload.
|
||||||
|
|
||||||
|
The cold-start arrival gate prevents default promotion even though movement readiness and tick behavior improved. No further adjustment was made after the seven-pair result.
|
||||||
|
|
||||||
|
## Defaults and checks
|
||||||
|
|
||||||
|
| CVar | Final default | Candidate test value |
|
||||||
|
|---|---:|---:|
|
||||||
|
| `voxel.StreamingFastTravelWaitForAllCriticalTiles` | 1 | 0 |
|
||||||
|
| `voxel.StreamingFastTravelApplyBudgetMilliseconds` | 0 ms | 1 ms |
|
||||||
|
| `voxel.StreamingIncrementalDesiredSet` | 1 | 1 |
|
||||||
|
| `voxel.StreamingFastTravelForwardFirst` | 0 | 0 |
|
||||||
|
| `voxel.StreamingRebuildOnCoreRegionCompletion` | 1 | 1 |
|
||||||
|
| `voxel.StreamingCoreDesiredCacheByCenter` | 0 | 0 |
|
||||||
|
| `voxel.StreamingIncrementalDesiredAuditEvery` | 0 | 0 |
|
||||||
|
| `voxel.StreamingIncrementalDesiredMaterializationAuditEvery` | 0 | 0 |
|
||||||
|
| `voxel.StreamingIncrementalDesiredAuditInjectMismatch` | 0 | 0 |
|
||||||
|
|
||||||
|
- Final VFHost Linux Development build passed with `-NoUBA -MaxParallelActions=4`; three existing deprecated RealtimeMesh API warnings remain.
|
||||||
|
- Final-source `verify.py fast` passed in 289.8 s: 268 passes, 0 failures, 5 diagnostics. Clang and GCC geometry hashes and W1/W8/W12 determinism passed.
|
||||||
|
- `git diff --check` and the runner AST syntax check passed. The direct live `TileHashDump` hook returned zero records for the Core path on both binaries and is inconclusive; the fast-tier geometry and determinism checks passed.
|
||||||
|
- UE W12 movement was not run: this VFHost procedure exposed eight effective tile slots. Fast-tier W12 determinism was run.
|
||||||
|
|
||||||
|
Remaining risk: desired rebuilds still reach 7.260 ms in the candidate, and the apply estimate cannot bound an atomic RMC upload. Most importantly, the arrival gate failed, so early admission remains opt-in.
|
||||||
|
|
||||||
|
Changed Unreal `Source/` files:
|
||||||
|
|
||||||
|
- `Source/VoxelForge/Private/VoxelWorld.cpp`
|
||||||
|
- `Source/VoxelForge/Public/VoxelWorld.h`
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""L169 default-based UE movement comparison and early-admission spike trace."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import shutil
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
HOST = Path("/home/jahni/VFHost")
|
||||||
|
EDITOR = Path("/home/jahni/UnrealEngine/Engine/Binaries/Linux/UnrealEditor-Cmd")
|
||||||
|
HOST_PLUGIN = HOST / "Binaries/Linux/libUnrealEditor-VoxelForge.so"
|
||||||
|
OUT = ROOT / "Saved/Linux/l169/ue/movement"
|
||||||
|
BIN_DIR = ROOT / "Saved/Linux/l169/bin"
|
||||||
|
SPAWN_M = (19.166015625, -14.2001953125, -753.3740234375)
|
||||||
|
MOVE_START_S = 6.0
|
||||||
|
MOVE_DURATION_S = 12.0
|
||||||
|
|
||||||
|
BASELINE_BINARY = BIN_DIR / "ca0bf76/libUnrealEditor-VoxelForge.so"
|
||||||
|
CANDIDATE_BINARY = BIN_DIR / "early-admission/libUnrealEditor-VoxelForge.so"
|
||||||
|
DIAGNOSTIC_BINARY = BIN_DIR / "apply-preflight/libUnrealEditor-VoxelForge.so"
|
||||||
|
|
||||||
|
EXPECTED = {
|
||||||
|
"baseline": {"wait": 1, "apply_budget_ms": 0.0, "incremental": 1},
|
||||||
|
"early_override": {"wait": 0, "apply_budget_ms": 1.0, "incremental": 1},
|
||||||
|
"candidate": {"wait": 0, "apply_budget_ms": 1.0, "incremental": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
ORDER = {
|
||||||
|
1: ("baseline", "candidate"),
|
||||||
|
2: ("candidate", "baseline"),
|
||||||
|
3: ("baseline", "candidate"),
|
||||||
|
4: ("candidate", "baseline"),
|
||||||
|
5: ("baseline", "candidate"),
|
||||||
|
6: ("candidate", "baseline"),
|
||||||
|
7: ("baseline", "candidate"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(values: list[float], fraction: float = 0.99) -> float | None:
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
ordered = sorted(values)
|
||||||
|
index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1))
|
||||||
|
return ordered[index]
|
||||||
|
|
||||||
|
|
||||||
|
def percent(numerator: int | float, denominator: int | float) -> float | None:
|
||||||
|
return 100.0 * numerator / denominator if denominator > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_events(path: Path) -> list[dict]:
|
||||||
|
rows = []
|
||||||
|
if path.is_file():
|
||||||
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||||
|
if not line.startswith("{"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(condition: str, run_name: str, trace: Path, log: Path, exit_code: int,
|
||||||
|
wall_s: float, audit_or_diag: bool = False) -> dict:
|
||||||
|
expected = EXPECTED[condition]
|
||||||
|
events = read_events(trace)
|
||||||
|
backend = next((e for e in events if e.get("type") == "worker_backend_config"), {})
|
||||||
|
scheduler_all = [e for e in events if e.get("type") == "scheduler_admission"]
|
||||||
|
if not scheduler_all:
|
||||||
|
raise RuntimeError(f"missing scheduler readback: {trace}")
|
||||||
|
if any(int(e.get("incremental_desired_set", -1)) != expected["incremental"]
|
||||||
|
for e in scheduler_all):
|
||||||
|
raise RuntimeError(f"incremental desired-set readback mismatch: {trace}")
|
||||||
|
if any(int(e.get("forward_first", -1)) != 0
|
||||||
|
or int(e.get("rebuild_on_core_region_completion", -1)) != 1
|
||||||
|
or int(e.get("core_desired_cache_by_center", -1)) != 0
|
||||||
|
or int(e.get("incremental_desired_audit_every", -1)) != 0
|
||||||
|
or int(e.get("incremental_desired_materialization_audit_every", -1)) != 0
|
||||||
|
or int(e.get("incremental_desired_audit_inject_mismatch", -1)) != 0
|
||||||
|
for e in scheduler_all):
|
||||||
|
raise RuntimeError(f"non-default L160/audit CVar readback: {trace}")
|
||||||
|
if (int(backend.get("streaming_fast_travel_wait_for_all_critical_tiles", -1))
|
||||||
|
!= expected["wait"]
|
||||||
|
or abs(float(backend.get("streaming_fast_travel_apply_budget_ms", -999.0))
|
||||||
|
- expected["apply_budget_ms"]) > 0.01
|
||||||
|
or int(backend.get("effective_tile_slots", -1)) != 8):
|
||||||
|
raise RuntimeError(f"early-admission/worker readback mismatch: {trace}; {backend}")
|
||||||
|
|
||||||
|
move_stop = next((e for e in events if e.get("type") == "streaming_test_move_stopped"), None)
|
||||||
|
if move_stop is None:
|
||||||
|
raise RuntimeError(f"missing move-stop event: {trace}")
|
||||||
|
stop_s = float(move_stop.get("t_s", 0.0))
|
||||||
|
samples = [e for e in events if e.get("type") == "streaming_sample"]
|
||||||
|
moving = [e for e in samples if MOVE_START_S <= float(e.get("t_s", 0.0)) < stop_s
|
||||||
|
and float(e.get("speed_cm_s", 0.0)) >= 50.0]
|
||||||
|
dwell = [e for e in samples if float(e.get("t_s", 0.0)) >= stop_s
|
||||||
|
and float(e.get("speed_cm_s", 0.0)) < 50.0]
|
||||||
|
if exit_code != 0 or len(moving) < 40 or len(dwell) < 16:
|
||||||
|
raise RuntimeError(f"incomplete {run_name}: exit={exit_code}, move={len(moving)}, "
|
||||||
|
f"dwell={len(dwell)}")
|
||||||
|
move_start = min(float(e.get("t_s", 0.0)) for e in moving)
|
||||||
|
|
||||||
|
work = [e for e in events if e.get("type") == "game_thread_work_frame"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s]
|
||||||
|
scheduler_moving = [e for e in scheduler_all
|
||||||
|
if move_start <= float(e.get("t_s", 0.0)) < stop_s]
|
||||||
|
rebuild_events = [e for e in events if e.get("type") == "desired_rebuild_breakdown"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s]
|
||||||
|
applies = {int(e.get("frame", -1)): e for e in events
|
||||||
|
if e.get("type") == "apply_frame"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s}
|
||||||
|
scheduler = {int(e.get("frame", -1)): e for e in scheduler_all
|
||||||
|
if move_start <= float(e.get("t_s", 0.0)) < stop_s}
|
||||||
|
desired = {int(e.get("frame", -1)): e for e in events
|
||||||
|
if e.get("type") == "desired_rebuild_breakdown"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s}
|
||||||
|
moving_tiles = [e for e in events if e.get("type") == "tile"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s]
|
||||||
|
apply_deferrals = [e for e in events if e.get("type") == "tile_apply_deferred"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s]
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
for event in work:
|
||||||
|
frame = int(event.get("frame", -1))
|
||||||
|
apply = applies.get(frame, {})
|
||||||
|
admit = scheduler.get(frame, {})
|
||||||
|
rebuild = desired.get(frame, {})
|
||||||
|
frames.append({
|
||||||
|
"frame": frame,
|
||||||
|
"tick_ms": float(event.get("tick_ms", 0.0)),
|
||||||
|
"update_chunks_ms": float(event.get("update_chunks_ms", 0.0)),
|
||||||
|
"pending_drain_ms": float(event.get("pending_drain_ms", 0.0)),
|
||||||
|
"apply_ms": float(apply.get("apply_ms", 0.0)),
|
||||||
|
"apply_budget_ms": float(apply.get("apply_budget_ms", 0.0)),
|
||||||
|
"meshes_applied": int(apply.get("meshes_applied", 0)),
|
||||||
|
"time_budget_hit": int(apply.get("time_limit_reached", 0)),
|
||||||
|
"desired_rebuild_ms": float(admit.get("desired_rebuild_ms", 0.0)),
|
||||||
|
"desired_rebuild_event_ms": float(rebuild.get("total_ms", 0.0)),
|
||||||
|
"overlay_materialize_ms": float(rebuild.get("overlay_materialize_ms", 0.0)),
|
||||||
|
"core_prepare_ms": float(rebuild.get("core_prepare_ms", 0.0)),
|
||||||
|
"core_delta_ms": float(rebuild.get("core_delta_ms", 0.0)),
|
||||||
|
"priority_sort_ms": float(rebuild.get("priority_sort_ms", 0.0)),
|
||||||
|
"critical_order_ms": float(rebuild.get("critical_order_ms", 0.0)),
|
||||||
|
"collision_stamp_ms": float(rebuild.get("collision_stamp_ms", 0.0)),
|
||||||
|
"transition_index_ms": float(rebuild.get("transition_index_ms", 0.0)),
|
||||||
|
"retained_region_ms": float(rebuild.get("retained_region_ms", 0.0)),
|
||||||
|
"region_reconcile_ms": float(rebuild.get("region_reconcile_ms", 0.0)),
|
||||||
|
"level_sort_ms": float(admit.get("level_sort_ms", 0.0)),
|
||||||
|
"level_candidate_scan_ms": float(admit.get("level_candidate_scan_ms", 0.0)),
|
||||||
|
"submit_tiles_ms": float(admit.get("submit_tiles_ms", 0.0)),
|
||||||
|
})
|
||||||
|
|
||||||
|
near = []
|
||||||
|
cone = []
|
||||||
|
far_holes = []
|
||||||
|
for event in moving:
|
||||||
|
desired_by_lod = event.get("desired_by_lod", [0] * 9)
|
||||||
|
resident_by_lod = event.get("resident_by_lod", [0] * 9)
|
||||||
|
covered_by_lod = event.get("covered_by_lod", resident_by_lod)
|
||||||
|
near.append(percent(sum(resident_by_lod[:3]), sum(desired_by_lod[:3])) or 0.0)
|
||||||
|
cone.append(percent(int(event.get("near_forward_cone_resident", 0)),
|
||||||
|
int(event.get("near_forward_cone_desired", 0))) or 0.0)
|
||||||
|
far_holes.append(percent(sum(desired_by_lod[3:]) - sum(covered_by_lod[3:]),
|
||||||
|
sum(desired_by_lod[3:])) or 0.0)
|
||||||
|
settled = next((e for e in samples if float(e.get("t_s", 0.0)) < move_start
|
||||||
|
and float(e.get("speed_cm_s", 0.0)) < 50.0
|
||||||
|
and sum(e.get("settled_by_lod", [0] * 9)[:3])
|
||||||
|
>= sum(e.get("desired_by_lod", [0] * 9)[:3]) > 0), None)
|
||||||
|
p99 = percentile([r["tick_ms"] for r in frames])
|
||||||
|
worst = max(frames, key=lambda r: r["tick_ms"], default={})
|
||||||
|
row = {
|
||||||
|
"condition": condition,
|
||||||
|
"run": run_name,
|
||||||
|
"diagnostic_override": audit_or_diag,
|
||||||
|
"source_default_incremental": expected["incremental"],
|
||||||
|
"early_admission_wait_for_critical": expected["wait"],
|
||||||
|
"early_admission_apply_budget_ms": expected["apply_budget_ms"],
|
||||||
|
"speed_mps": 30,
|
||||||
|
"effective_tile_slots": backend.get("effective_tile_slots"),
|
||||||
|
"moving_samples": len(moving),
|
||||||
|
"dwell_samples": len(dwell),
|
||||||
|
"tick_p99_ms": p99,
|
||||||
|
"tick_worst_ms": worst.get("tick_ms"),
|
||||||
|
"worst_frame": worst,
|
||||||
|
"near_readiness_moving_mean_pct": statistics.mean(near) if near else None,
|
||||||
|
"forward_cone_readiness_moving_mean_pct": statistics.mean(cone) if cone else None,
|
||||||
|
"far_holes_moving_median_pct": statistics.median(far_holes) if far_holes else None,
|
||||||
|
"arrival_lod02_settled_s": float(settled.get("t_s", 0.0)) if settled else None,
|
||||||
|
"desired_rebuild_count": len(rebuild_events),
|
||||||
|
"incremental_materialization_updates": sum(
|
||||||
|
int(e.get("materialization_update", 0)) != 0 for e in events
|
||||||
|
if e.get("type") == "desired_rebuild_breakdown"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s),
|
||||||
|
"core_incremental_deltas": sum(
|
||||||
|
int(e.get("incremental_delta", 0)) != 0 for e in events
|
||||||
|
if e.get("type") == "core_desired_cache"
|
||||||
|
and move_start <= float(e.get("t_s", 0.0)) < stop_s),
|
||||||
|
"level_sort_samples_over_1ms": sum(
|
||||||
|
float(e.get("level_sort_ms", 0.0)) > 1.0 for e in scheduler_moving),
|
||||||
|
"level_sort_max_ms": max((float(e.get("level_sort_ms", 0.0))
|
||||||
|
for e in scheduler_moving), default=0.0),
|
||||||
|
"desired_rebuild_events_over_5ms": sum(
|
||||||
|
float(e.get("total_ms", 0.0)) > 5.0 for e in rebuild_events),
|
||||||
|
"desired_rebuild_max_ms": max(
|
||||||
|
(float(e.get("total_ms", 0.0)) for e in rebuild_events), default=0.0),
|
||||||
|
"overlay_materialize_events_over_2ms": sum(
|
||||||
|
float(e.get("overlay_materialize_ms", 0.0)) > 2.0 for e in rebuild_events),
|
||||||
|
"overlay_materialize_max_ms": max(
|
||||||
|
(float(e.get("overlay_materialize_ms", 0.0)) for e in rebuild_events), default=0.0),
|
||||||
|
"desired_subphase_max_ms": {
|
||||||
|
key: max((float(e.get(key, 0.0)) for e in rebuild_events), default=0.0)
|
||||||
|
for key in ("core_prepare_ms", "overlay_materialize_ms", "priority_sort_ms",
|
||||||
|
"critical_order_ms", "collision_stamp_ms", "transition_index_ms",
|
||||||
|
"retained_region_ms", "region_reconcile_ms")},
|
||||||
|
"apply_budget_hit_frames": sum(f["time_budget_hit"] for f in frames),
|
||||||
|
"apply_frame_count": sum(f["meshes_applied"] > 0 for f in frames),
|
||||||
|
"apply_ms_max": max((f["apply_ms"] for f in frames), default=0.0),
|
||||||
|
"tiles_over_1ms_apply": sum(float(e.get("apply_s", 0.0)) > 0.001
|
||||||
|
for e in moving_tiles),
|
||||||
|
"apply_preflight_deferred_count": len(apply_deferrals),
|
||||||
|
"apply_preflight_deferred_max_estimate_ms": max(
|
||||||
|
(float(e.get("estimated_apply_ms", 0.0)) for e in apply_deferrals), default=0.0),
|
||||||
|
"apply_preflight_deferred_max_triangles": max(
|
||||||
|
(int(e.get("triangles", 0)) for e in apply_deferrals), default=0),
|
||||||
|
"largest_tile_applies": sorted(({
|
||||||
|
"apply_ms": float(e.get("apply_s", 0.0)) * 1000.0,
|
||||||
|
"triangles": int(e.get("triangles", 0)),
|
||||||
|
"vertices": int(e.get("vertices", 0)),
|
||||||
|
"level": int(e.get("level", -1)),
|
||||||
|
} for e in moving_tiles),
|
||||||
|
key=lambda e: e["apply_ms"], reverse=True)[:8],
|
||||||
|
"top_ticks": sorted(frames, key=lambda r: r["tick_ms"], reverse=True)[:8],
|
||||||
|
"trace": str(trace.relative_to(ROOT)),
|
||||||
|
"log": str(log.relative_to(ROOT)),
|
||||||
|
"wall_s": round(wall_s, 3),
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def run(condition: str, run_name: str, diagnostic_override: bool = False,
|
||||||
|
tile_hash_dump: Path | None = None) -> dict:
|
||||||
|
if condition == "baseline":
|
||||||
|
binary = BASELINE_BINARY
|
||||||
|
elif condition == "early_override":
|
||||||
|
binary = DIAGNOSTIC_BINARY if DIAGNOSTIC_BINARY.is_file() else BASELINE_BINARY
|
||||||
|
elif condition == "candidate":
|
||||||
|
binary = CANDIDATE_BINARY
|
||||||
|
else:
|
||||||
|
raise ValueError(condition)
|
||||||
|
if not binary.is_file():
|
||||||
|
raise FileNotFoundError(binary)
|
||||||
|
HOST_PLUGIN.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(binary, HOST_PLUGIN)
|
||||||
|
out = OUT / condition
|
||||||
|
export = HOST / "Plugins/VoxelForge/Saved/Linux/l169" / condition / run_name
|
||||||
|
userdir = HOST / "Saved/Linux/l169/user" / condition / run_name
|
||||||
|
zendata = HOST / "Saved/Linux/l169/zen" / condition / run_name
|
||||||
|
ddc = HOST / "Saved/Linux/l169/DDC"
|
||||||
|
trace = out / f"{run_name}.trace.jsonl"
|
||||||
|
log = out / f"{run_name}.log"
|
||||||
|
stdout = out / f"{run_name}.stdout.log"
|
||||||
|
for directory in (out, export, userdir, zendata, ddc):
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
trace.unlink(missing_ok=True)
|
||||||
|
spawn_cm = ",".join(f"{value * 100.0:.3f}" for value in SPAWN_M)
|
||||||
|
command = [
|
||||||
|
str(EDITOR), str(HOST / "VFHost.uproject"), "-nullrhi", "-unattended",
|
||||||
|
"-nop4", "-nosplash", "-NoSound", "-corelimit=12", "-NoZenAutoLaunch",
|
||||||
|
"-notraceserver", "-nocrashreports", "-DisablePlugins=AndroidFileServer",
|
||||||
|
"-ddc=NoZenLocalFallback", f"-userdir={userdir}", f"-ZenDataPath={zendata}",
|
||||||
|
f"-LocalDataCachePath={ddc}", "-run=VoxelForgeExplore", "-seed=0",
|
||||||
|
"-archetype=TunnelNetwork", "-slot=0", "-modes=export", "-gameconfig=1",
|
||||||
|
"-coreworldtest=1", "-ownerfourstrateplan=1", "-corevoxelsizecm=25",
|
||||||
|
"-coreislandtest=0", "-corefallbacktest=0", "-coreofftest=0",
|
||||||
|
"-coreplantest=0", "-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings",
|
||||||
|
"-corehybridmesher=0", f"-out={export}",
|
||||||
|
"-voxel.TestMove=1", "-voxel.TestMoveSpeedCmPerSecond=3000.0",
|
||||||
|
f"-voxel.TestMoveStartSeconds={MOVE_START_S:.1f}",
|
||||||
|
f"-voxel.TestMoveDurationSeconds={MOVE_DURATION_S:.1f}",
|
||||||
|
"-voxel.TestExitSeconds=0.0", "-voxel.TestMoveDirection=1,0,0",
|
||||||
|
"-voxel.TestExitOnSteady=0", "-coreworldstreamtrace=1",
|
||||||
|
f"-voxel.StartupTraceSpawnCm={spawn_cm}",
|
||||||
|
"-voxel.StartupTraceUntilEndPlay=1", "-voxel.StartupTraceThroughCrossing=1",
|
||||||
|
"-voxel.StartupTraceMaxTiles=16384", f"-voxel.StartupTraceFile={trace}",
|
||||||
|
f"-abslog={log}",
|
||||||
|
]
|
||||||
|
if condition in ("early_override", "candidate"):
|
||||||
|
command += ["-voxel.StreamingFastTravelWaitForAllCriticalTiles=0",
|
||||||
|
"-voxel.StreamingFastTravelApplyBudgetMilliseconds=1.0"]
|
||||||
|
if tile_hash_dump is not None:
|
||||||
|
tile_hash_dump.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tile_hash_dump.unlink(missing_ok=True)
|
||||||
|
command.append(f"-voxel.TileHashDump={tile_hash_dump.resolve()}")
|
||||||
|
started = time.monotonic()
|
||||||
|
with stdout.open("w", encoding="utf-8") as stream:
|
||||||
|
process = subprocess.run(
|
||||||
|
["taskset", "-c", "0-11", "timeout", "1740s", *command],
|
||||||
|
cwd=ROOT, stdout=stream, stderr=subprocess.STDOUT, check=False, timeout=1740)
|
||||||
|
return summarize(condition, run_name, trace, log, process.returncode,
|
||||||
|
time.monotonic() - started, diagnostic_override)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
modes = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
modes.add_argument("--diagnostic", action="store_true",
|
||||||
|
help="one early-admission trace on the selected diagnostic binary")
|
||||||
|
modes.add_argument("--matrix", action="store_true",
|
||||||
|
help="warm both binaries, then run seven interleaved pairs with only the early-admission switches overridden")
|
||||||
|
modes.add_argument("--geometry-hash-check", action="store_true",
|
||||||
|
help="compare shared tile hashes with defaults vs the promoted switches")
|
||||||
|
args = parser.parse_args()
|
||||||
|
missing = [p for p in (EDITOR, HOST / "VFHost.uproject", ROOT / "VoxelForge.uplugin")
|
||||||
|
if not p.exists()]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(f"missing UE runner input: {missing}")
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if args.diagnostic:
|
||||||
|
warmup = run("early_override", "diagnostic-warmup", diagnostic_override=True)
|
||||||
|
row = run("early_override", "l169-default-base-diagnostic", diagnostic_override=True)
|
||||||
|
row["warmup"] = warmup
|
||||||
|
path = OUT / "diagnostic.json"
|
||||||
|
path.write_text(json.dumps(row, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
print("[L169Diagnostic] " + json.dumps(row, sort_keys=True), flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.geometry_hash_check:
|
||||||
|
hash_dir = OUT / "geometry-hash"
|
||||||
|
baseline_dump = hash_dir / "baseline.json"
|
||||||
|
candidate_dump = hash_dir / "candidate.json"
|
||||||
|
run("baseline", "geometry-hash", tile_hash_dump=baseline_dump)
|
||||||
|
run("candidate", "geometry-hash", tile_hash_dump=candidate_dump)
|
||||||
|
baseline_data = json.loads(baseline_dump.read_text(encoding="utf-8"))
|
||||||
|
candidate_data = json.loads(candidate_dump.read_text(encoding="utf-8"))
|
||||||
|
key_fields = ("level", "tile_x", "tile_y", "tile_z")
|
||||||
|
|
||||||
|
def indexed(data: dict) -> dict[tuple[int, ...], tuple]:
|
||||||
|
return {tuple(int(record[field]) for field in key_fields): (
|
||||||
|
str(record.get("geometry_hash", "")),
|
||||||
|
int(record.get("triangle_count", -1)),
|
||||||
|
bool(record.get("empty", False)))
|
||||||
|
for record in data.get("records", [])}
|
||||||
|
|
||||||
|
base = indexed(baseline_data)
|
||||||
|
candidate = indexed(candidate_data)
|
||||||
|
common = base.keys() & candidate.keys()
|
||||||
|
mismatches = [key for key in common if base[key] != candidate[key]]
|
||||||
|
result = {"baseline_records": len(base), "candidate_records": len(candidate),
|
||||||
|
"common_records": len(common), "hash_mismatches": len(mismatches),
|
||||||
|
"baseline_only_records": len(base.keys() - candidate.keys()),
|
||||||
|
"candidate_only_records": len(candidate.keys() - base.keys()),
|
||||||
|
"baseline_dump": str(baseline_dump.relative_to(ROOT)),
|
||||||
|
"candidate_dump": str(candidate_dump.relative_to(ROOT))}
|
||||||
|
(hash_dir / "comparison.json").write_text(
|
||||||
|
json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
print("[L169GeometryHash] " + json.dumps(result, sort_keys=True), flush=True)
|
||||||
|
return 0 if len(common) >= 100 and not mismatches else 1
|
||||||
|
|
||||||
|
warmups = [run("baseline", "warmup"), run("candidate", "warmup")]
|
||||||
|
rows = []
|
||||||
|
partial = OUT / "runs-partial.json"
|
||||||
|
for pair, sequence in ORDER.items():
|
||||||
|
for condition in sequence:
|
||||||
|
rows.append(run(condition, f"pair-{pair}"))
|
||||||
|
partial.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
metrics = ("tick_p99_ms", "tick_worst_ms", "near_readiness_moving_mean_pct",
|
||||||
|
"forward_cone_readiness_moving_mean_pct", "far_holes_moving_median_pct",
|
||||||
|
"arrival_lod02_settled_s")
|
||||||
|
medians = {condition: {metric: statistics.median(
|
||||||
|
row[metric] for row in rows if row["condition"] == condition
|
||||||
|
and row.get(metric) is not None) for metric in metrics}
|
||||||
|
for condition in ("baseline", "candidate")}
|
||||||
|
by_pair = {pair: {row["condition"]: row for row in rows if row["run"] == f"pair-{pair}"}
|
||||||
|
for pair in ORDER}
|
||||||
|
deltas = [
|
||||||
|
{"pair": pair, **{f"{metric}_delta":
|
||||||
|
by_pair[pair]["candidate"][metric] - by_pair[pair]["baseline"][metric]
|
||||||
|
for metric in metrics}}
|
||||||
|
for pair in ORDER
|
||||||
|
]
|
||||||
|
summary = {"protocol": "Seven interleaved 30 m/s UE pairs using base defaults except for "
|
||||||
|
"the two early-admission switches at candidate values; 12 s move plus 5 s "
|
||||||
|
"dwell; eight effective tile slots; one warm-up per binary excluded.",
|
||||||
|
"defaults": {"incremental_desired_set": 1,
|
||||||
|
"streaming_fast_travel_wait_for_all_critical_tiles": 1,
|
||||||
|
"streaming_fast_travel_apply_budget_ms": 0.0,
|
||||||
|
"forward_first": 0, "rebuild_on_core_region_completion": 1,
|
||||||
|
"core_desired_cache_by_center": 0,
|
||||||
|
"incremental_desired_audit_every": 0,
|
||||||
|
"incremental_desired_materialization_audit_every": 0,
|
||||||
|
"incremental_desired_audit_inject_mismatch": 0},
|
||||||
|
"candidate_switch_overrides": {
|
||||||
|
"streaming_fast_travel_wait_for_all_critical_tiles": 0,
|
||||||
|
"streaming_fast_travel_apply_budget_ms": 1.0},
|
||||||
|
"warmups": warmups, "movement_medians": medians,
|
||||||
|
"paired_deltas_candidate_minus_baseline": deltas, "runs": rows}
|
||||||
|
(OUT / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print("[L169Summary] " + json.dumps({"movement_medians": medians,
|
||||||
|
"paired_deltas": deltas}, sort_keys=True), flush=True)
|
||||||
|
all_runs_p99_pass = all(row["tick_p99_ms"] is not None and row["tick_p99_ms"] <= 11.0
|
||||||
|
for row in rows)
|
||||||
|
all_runs_worst_pass = all(row["tick_worst_ms"] is not None
|
||||||
|
and row["tick_worst_ms"] <= 14.0 for row in rows)
|
||||||
|
candidate_worst = [row["tick_worst_ms"] for row in rows
|
||||||
|
if row["condition"] == "candidate"]
|
||||||
|
baseline_worst = [row["tick_worst_ms"] for row in rows
|
||||||
|
if row["condition"] == "baseline"]
|
||||||
|
gates = {
|
||||||
|
"every_run_p99_le_11_ms": all_runs_p99_pass,
|
||||||
|
"every_run_worst_le_14_ms": all_runs_worst_pass,
|
||||||
|
"candidate_worst_tick_median_not_worse": bool(candidate_worst and baseline_worst)
|
||||||
|
and statistics.median(candidate_worst) <= statistics.median(baseline_worst),
|
||||||
|
"near_readiness_up": medians["candidate"]["near_readiness_moving_mean_pct"]
|
||||||
|
> medians["baseline"]["near_readiness_moving_mean_pct"],
|
||||||
|
"forward_cone_readiness_up": medians["candidate"][
|
||||||
|
"forward_cone_readiness_moving_mean_pct"]
|
||||||
|
> medians["baseline"]["forward_cone_readiness_moving_mean_pct"],
|
||||||
|
"far_holes_not_worse": medians["candidate"]["far_holes_moving_median_pct"]
|
||||||
|
<= medians["baseline"]["far_holes_moving_median_pct"],
|
||||||
|
"arrival_not_worse": medians["candidate"]["arrival_lod02_settled_s"]
|
||||||
|
<= medians["baseline"]["arrival_lod02_settled_s"],
|
||||||
|
}
|
||||||
|
summary["promotion_gates"] = gates
|
||||||
|
(OUT / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
print("[L169Gates] " + json.dumps(gates, sort_keys=True), flush=True)
|
||||||
|
return 0 if all(gates.values()) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in new issue
Block a user