diff --git a/PERF-ATTRIBUTION.md b/PERF-ATTRIBUTION.md new file mode 100644 index 0000000..5dca6ed --- /dev/null +++ b/PERF-ATTRIBUTION.md @@ -0,0 +1,93 @@ +# PERF-ATTRIBUTION + +Measured 2026-09-12 at `cf0319d` (`experimental`). This was measurement-only: the source changes add +diagnostic counters and clocks; they do not change density or meshing decisions. The primary case is +the 128^3 `TunnelNetwork`, seed 1337, slot 1, operator-stack setting 1, origin `(-64,-64,-224)` +voxels, step 1, 64 canonical 32^3 tiles, and 15 workers. + +`clean` means diagnostics off and `-blockearlyout=0`, with fused evaluation and shared density-grid +reuse enabled. The three clean primary runs used an exact-HEAD DLL staged before instrumentation: +603.599, 590.746, and 569.627 ms mesh time, mean **587.991 ms**. The requested 0.556 s is therefore +within the observed run spread but was not reproduced exactly on this host. `attr` means the final +instrumented DLL, `-perfattribution`, sample interval 128. Its wall-clock self-check compares the +same export against an unprofiled run in the same process. + +The prescribed build was run successfully with: + +```text +dotnet "E:\Program Files\Epic Games\UE_5.7\Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll" UnrealEditor Win64 Development "-Project=E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BuildHost\HostProject\HostProject.uproject" -WaitMutex -FromMsBuild -architecture=x64 -NoUBA "-Log=E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\PerfAttributionBuild.log" +``` + +The measured process loaded the staged runtime DLL at +`E:\Projet Unreal\VoxelM\Plugins\VoxelForge\Saved\BuildHost\HostProject\Plugins\VoxelForge\Binaries\Win64\UnrealEditor-VoxelForge.dll`, SHA-256 +`C0CB812BB25EFE8C836DC1C1718C408DA5FE9D9F258B4AE12A6CA846510CA0B2`, length 7,673,856 bytes. The +staged editor DLL was `E64C9E80869EFA53FA1556947718DB8E64975ABF6B546657BCFF5D775A2B679A`, length +502,784 bytes. The game harness recorded the same runtime path and hash. The primary attribution and +final classifier exports returned `status=ok`, geometry CRC `07C14005`, and the required OBJ SHA-256 +`b3e5f4c398dd0547c6c55f415872058246d4a292c2f0d636ee1cd0829de9b377`. + +The table is the attribution. Only rows marked **additive** are summed. Profiler component rows are +sampled nested diagnostics; their CPU and worker-wall figures are evidence about composition, not +additional time to add to the wall ledger. + +| Bucket / question | Measurement and mode | Elapsed time, volume, or count | Whole-clock accounting / interpretation | +|---|---|---:|---| +| Historical comparison | `34f06df` value supplied in the request | 130.000 ms | Historical pre-op-stack reference; the exact old binary was not rebuilt because that commit predates the current editor commandlet and host harness. | +| Current clean primary | 3 exports, exact `cf0319d` DLL, diagnostics **off**, fused=1, reuse=1, classifier early-out=0 | **587.991 ms mean**; 569.627–603.599 ms | Clean headline mesh clock. Mean is 4.523x the supplied 130 ms reference. | +| Primary attribution self-check | Final DLL, `attr`; profiled 606.128 ms versus same-argument unprofiled 602.341 ms | 1.0063x; +0.629% | Passed. Geometry equal. The profiler did not reproduce the 11.5x inflation seen in the earlier diagnostic mode. | +| Full commandlet export | Final DLL, `attr`; mesh plus output and bookkeeping | **1,004.557 ms** | Full export wall, not just mesh generation. The OBJ writer is a material part of this clock. | +| Full export: mesh generation | Additive export ledger | 606.128 ms; 60.338% of export | Includes the complete additive mesh ledger below. | +| Full export: OBJ/output interval | Additive; includes nested `MeasureExploreMesh` | 394.273 ms; 39.249% | `measure_seconds` is 82.964 ms inside this interval and is not added again. | +| Full export: geometry hash | Additive | 2.946 ms; 0.293% | CRC/hash bookkeeping. | +| Full export: manifest | Additive | 0.984 ms; 0.098% | Manifest write. | +| Full export: remainder | Additive export remainder | 0.225 ms; 0.022% | Export ledger remainder. Mesh + OBJ interval + hash + manifest + remainder = 1,004.557 ms. | +| Mesh ledger: preparation | **Additive** | 0.000101 ms; 0.0000% of mesh | No meaningful cost. | +| Mesh ledger: shared density-grid fill | **Additive**; 2,248,091 unique shared samples | **590.923 ms; 97.4913% of mesh** | This is the dominant remaining cost. It is the caller-side density-grid stage and includes the canonical fused `GetDensityAt` work. | +| Mesh ledger: pre-tile setup | **Additive** | 0.001602 ms; 0.0003% | No meaningful cost. | +| Mesh ledger: tile parallel wall | **Additive**; 64 jobs, 15 workers | 10.972 ms; 1.8102% | Per-tile cell scan, interpolation, normals, stream assembly, and task scheduling after the shared grid exists. | +| Mesh ledger: tile merge | **Additive** | 4.232 ms; 0.6982% | Cross-tile output merge. | +| Mesh ledger: accounting remainder | **Additive** | 0.000097 ms; 0.0000% | Stage sum 606.128205 ms versus mesh clock 606.128301 ms; `sum_matches_mesh=true`. Nothing is hidden in this remainder. | +| Mesh ledger total | Final DLL, `attr` | **606.128301 ms** | Complete mesh wall clock. | +| `GetDensityAt` parent | Final primary, `attr`; 2,248,091 calls, 17,558 sampled parent intervals, 15 workers | 580.184 ms worker-wall estimate | Nested inside the 590.923 ms density-grid stage. The remaining 10.738 ms is grid-loop/task/read overhead outside the parent scope; it is accounted for by the additive density-grid row. | +| Fused evaluator itself | Final primary, `attr`; 17,571 sampled component scopes; 2,248,091 fused tunnel samples | 107.183 ms sampled CPU; 12.301 ms sampled worker wall | Diagnostic sample only, not additive. Detail ran for 1,079,777 samples and was skipped for 1,168,314, about 52.0% of fused lattice samples. | +| Fused density core | Final primary, `attr`; sampled nested scope | 108.371 ms sampled CPU; 12.377 ms sampled worker wall | Diagnostic sample only. It is not a 108 ms total bucket and must not be added to the mesh ledger. | +| Room/tunnel graph traversal | Final primary counters | Rooms: 11,099,891 candidates / 1,730,920 evaluated; tunnels: 28,951,420 candidates / 3,462,565 evaluated; tunnel-core: 28,951,420 candidates / 1,881,812 evaluated | Room and tunnel candidate traversal is repeated inside the common density work. This is evidence of work volume, not a separately additive clock. | +| Room graph build and SDF | Final primary, `attr` | `RoomGraphBuild`: 77 calls, 192.146 ms sampled CPU / 19.323 ms worker wall; `RoomGraphSdf`: 12.101 ms / 0.989 ms sampled CPU/wall | The build is cold/cache construction work; SDF figures are nested samples. The room graph is not a large independent wall bucket on the primary fused route. | +| Tunnel-core world evaluation | Final primary, `attr` | 8.857 ms sampled CPU; 0.872 ms sampled worker wall | Nested diagnostic sample. The core traversal is present, but the measured evidence does not support calling it the whole 4.3x. | +| Post-stack structural/passage tail | Final primary, `attr`; `GetDensityAt` tail | `StructuralTail`: 20.853 ms CPU / 1.896 ms wall; `DensityStructuralPosts`: 21.909 ms / 1.990 ms; `PassageStructuralPosts`: 10.476 ms / 0.997 ms sampled CPU/wall | This is real current-only post-stack work, but it is a small sampled component relative to the 590.923 ms density-grid wall. All rows are nested and non-additive. Passage modifier was 4.579 ms CPU / 0.404 ms wall; landing-floor and room-floor samples were 1.691 ms / 0.177 ms and 0.665 ms / 0.060 ms. | +| Floor/profile work | Final primary counters, fused route; classifier figures from the separate classifier control | Primary support-column builds/queries/checks: 0 / 0 / 0; authored-floor samples: 0; floor-profile builds 2,636; segments 12,624; backstops 6,509 tunnel and 49 room. Classifier control: 211,289 support-column builds, 2,171,763 candidates, 1,370,314 floor queries, 2,296,243 checks, 9,710 authored-floor samples, 97,950 profiles and 442,278 profile segments. | The fused primary route does not execute the classifier’s support-column path. The classifier does, and that work is part of its separate 2.057 s route, not the clean 0.588 s headline. | +| Software tunnel cache | Final primary, `attr` | 2,248,043 hits, 48 misses, 77 cache builds | Cache lookup is not the removed verdict cache. The current tunnel cache is overwhelmingly hit; no hardware cache-miss counter was available. | +| Classifier absent from the clean headline | Clean exact-HEAD control, diagnostics **off**, classifier early-out=0 | 0 classifier calls | The stated 0.556 s case does not pay interval proofs. It is incorrect to charge classifier time to that baseline. | +| Classifier control when enabled | 3 clean exact-HEAD controls, diagnostics **off**, classifier early-out=1 | **2,056.596 ms mean** | +1,468.605 ms versus the clean primary mean; 3.498x the primary. This is a separate, expensive route, not the source of the clean 0.556 s. | +| Classifier total self-check | Final DLL, `attr`, classifier early-out=1 | 1,980.835 ms profiled versus 2,013.547 ms unprofiled; 0.9838x | Passed; geometry equal. Profile overhead was -1.62% in this run. | +| Interval proofs | Final classifier attribution; 32 sampled calls out of 4,096 | 494,976 refine/box calls; 479,872 whole-mixed, 15,104 whole-solid, 0 whole-air; 63,488 splits; max depth 4 | Proof detail is present and conservative, but its route is costly. The classifier’s scaled diagnostic work was 14,113.190 ms interval-box, 62.221 ms exact-core, and 399.770 ms exact-final microseconds; these are diagnostic work estimates, not additive wall time. | +| Classifier repeated room traversal | Final classifier attribution | 2,041,600 room-tail queries, 933,120 evaluated; 6,030,720 exact-core cache hits; 612,864 exact-core samples; 768 exact-final samples | The classifier does repeat room-graph work. Scaled diagnostic work was 11,423.590 ms room propagation, 4,665.818 ms exact primitive, 5,815.002 ms cache-window, and 1,227.366 ms room tail microseconds. | +| Shared grid traffic | Final primary, `attr` | 2,744,000 tile-grid samples; 2,248,091 unique shared samples; 495,909 duplicate evaluations before reuse, 0 after | The shared lattice removes duplicate evaluations, but filling the remaining 2.248 M samples is still 590.923 ms. | +| Mesher cell classification | Final primary, `attr`; nested sample | 2,097,152 cell calls; 1.344 ms sampled worker wall | The cell loop is not the dominant wall bucket. | +| Mesher vertex interpolation | Final primary, `attr`; nested sample | 76,808 calls; 0.012 ms sampled worker wall | Negligible in the observed sample. | +| Mesher gradient normals | Final primary, `attr`; nested sample | 76,808 calls; 1.056 ms sampled worker wall | Small nested cost; gradient normals come from the density grid. | +| Mesher stream building | Final primary, `attr`; nested sample | 76,808 calls; 3.140 ms sampled worker wall | Small relative to density-grid fill. Runtime stream handoff is not executed by the export commandlet. | +| Mesher output and memory | Final primary, `attr` | 82,273 vertices, 153,346 triangles; logical output arrays 7,434,716 bytes; allocated output 8,449,820 bytes; OBJ 17,587,937 bytes; estimated working set 14,851,564 bytes | Byte counters are logical allocator/output traffic, not physical DRAM traffic. | +| Grid read/write bytes | Final primary, `attr` | Density-grid bytes 10,976,000; shared-grid read bytes 10,976,000 | The instrumentation sees logical grid traffic. It does not see hardware cache misses, NUMA traffic, or memory-controller events. | +| Operator-block scratch/copy | Final primary, `attr` | 0 scratch bytes, 0 copy bytes, 0 builds, 0 operators | The old interpreted block machinery is not on the canonical primary route. | +| Operator-block control | Clean diagnostics-off controls with reuse=0 and fused=0 | 2,332.186 ms; 1,566 block builds; 2,486,750 block samples; 31,320 operator visits; 99,470,000 scratch bytes; 101,956,750 copy bytes | This is a deliberately separate block-path control, not the clean fused baseline. It demonstrates the traffic that is absent from the primary route. | +| Scalar and fused no-reuse controls | Clean diagnostics-off controls with reuse=0 | Scalar 2,733.080 ms; fused 2,218.132 ms | These controls show the cost of removing shared-grid reuse, but they are not attribution buckets for the primary run. | +| What `34f06df` did not do | Read-only source comparison at the historical commit | No active TunnelNetwork operator-stack evaluation, no fused evaluator, no current post-stack tunnel-floor/structural tail, no current editor export commandlet | The old source already had a shared density grid, `ClassifyTile`, marching-cubes cell classification, interpolation, grid-gradient normals, and stream construction. Those are not new current-only work. The old source also had room/tunnel morphology and passage carving, so “rooms/tunnels exist” alone is not a delta. | +| Primary operation-level attribution gap | Final primary profiler’s own ledger | Sampled phase sum 14.443 ms versus sampled `GetDensityAt` parent 580.184 ms; sampling gap 565.741 ms | This is the honest limit: component scopes were sampled to keep profiled/unprofiled totals within 0.63%, so their expanded operation-level wall shares are not identifiable from this run. The full mesh wall is nevertheless closed by the additive stage ledger, with a 0.000097 ms remainder. | +| Headless game clean check | `PerfAttribGameCleanFinal_20260912`; diagnostics **off**, tile profiling off; 343 LOD0-ready samples | request-to-ready p95 **0.375470 s**; queue wait p95 0.082292 s; generation p95 0.309538 s; collision cook p95 0.052945 s | Secondary check agrees with the earlier conclusion that queue wait is not the main generation constraint. | +| Headless game attribution check | `PerfAttribGameAttrFinal_20260912`; diagnostics **on**, tile profiling on; 834 profile lines, 822 applied | request-to-ready p95 **0.395715 s**; queue wait p95 0.083363 s; generation p95 0.315281 s; collision cook p95 0.061387 s | Attribution overhead was modest in the end-to-end game summary. Tile-profile p95 was 1.295847 s, of which classifier was 0.106626 s, mesh 1.284040 s, stream building 0.000361 s, room work 0.008842 s, and stack work 0.012019 s; these game tiles are not the same workload as the single 128^3 export. | + +The surprise is that the clean primary is almost entirely the shared density-grid fill: the ordinary +mesher stages after it total only about 15.204 ms. The fused evaluator is genuinely on the canonical +path, and the room/tunnel candidate counters show substantial repeated traversal, but the sampled +component timers cannot honestly turn that 590.923 ms into exact per-operation percentages without +reintroducing the profiler inflation this round was designed to avoid. The classifier is an even +larger cost when enabled, yet it is absent from the clean baseline. The old path already performed +the grid and marching-cubes phases, so those phases should not be described as newly introduced +work; the current-only items are the active fused route and the later structural/floor machinery. + +What could not be attributed: physical allocation-event counts, physical memory/cache misses, and an +exact numeric per-operation delta against the old `34f06df` binary. The old commit has no compatible +current export commandlet, and rebuilding it would not be the same measurement harness. The complete +current clocks, deterministic geometry, clean/profiled comparison, current counters, and all logical +memory-byte counters are accounted for above. Existing logs and outputs under `Saved\` were retained. diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 4179e66..3cd5c49 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -4729,6 +4729,8 @@ void VoxelCaveMorphology::BuildChunkCache( uint32 Seed, int32 StrateIndex, const TArray* TerrainOps) { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::RoomGraphBuild); // Clear previous data (arrays keep their allocation for reuse) OutCache.Reset(); OutCache.SDFBlendRadius = Params.SDFBlendRadius; @@ -6025,6 +6027,8 @@ float VoxelCaveMorphology::EvaluateSDFCached( float SDFBlendRadius, int32* OutNearestRoomIdx) { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::RoomGraphSdf); float MinSDF = FLT_MAX; const float BlendK = SDFBlendRadius; const FVector Pos(WorldX, WorldY, WorldZ); @@ -6273,6 +6277,8 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FChunkSDFCache& Cache, const FTunnelSupportFloorColumn* SupportColumn) { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCoreWorld); const FVector Pos(WorldX, WorldY, WorldZ); FTunnelCoreWorldEvaluation Result; @@ -6491,6 +6497,8 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( const FChunkSDFCache& Cache, FTunnelSupportFloorColumn& OutColumn) { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCoreSupport); OutColumn.Reset(); if (VoxelDensityProfile::AreCountersEnabled()) diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 36c2ea6..64ed49f 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -4009,6 +4009,8 @@ namespace void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::RoomGraphSource); FState& S = State(); S.LastWorldPosition = FVector(WorldX, WorldY, WorldZ); S.bLastTunnelCoreWorldEvaluationValid = false; diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index e1e0268..cf3ac41 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -2,10 +2,11 @@ namespace VoxelDensityProfile { + std::atomic GEnabled(false); + std::atomic GMode(EMode::Disabled); + namespace { - std::atomic GEnabled(false); - std::atomic GMode(EMode::Disabled); std::atomic GSampleInterval(DefaultSampleInterval); std::atomic GTimerPairCycles(0); @@ -24,6 +25,7 @@ namespace VoxelDensityProfile uint32 BlockCallCount[BucketCount]{}; uint64 BlockStartCycles[BucketCount]{}; bool BlockActive[BucketCount]{}; + FSnapshot::FClassifierStats Classifier; uint64 TunnelCacheCapacityEntries = 0; uint64 TunnelCacheValidEntries = 0; @@ -50,7 +52,6 @@ namespace VoxelDensityProfile thread_local FThreadState* GThreadState = nullptr; thread_local uint32 GScopeDepth = 0; thread_local bool GScopeSampled = false; - thread_local bool GSampledScopeActive = false; FThreadState& GetThreadState() { @@ -80,9 +81,9 @@ namespace VoxelDensityProfile bool IsSampledScopeEnabled(EBucket Bucket) { - // Sampled mode pays for the trustworthy parent and mesh containers only. Fine - // operation scopes remain available in Full mode, or through UE Insights scopes, - // without putting a Begin/End and thread-local bookkeeping on every density call. + // Sampled mode pays for the trustworthy parent and mesh containers only. Attribution + // additionally exposes the component scopes, but still samples them; Full remains the + // deliberately invasive mode for per-call scope timing. switch (Bucket) { case EBucket::GetDensityAt: @@ -94,6 +95,127 @@ namespace VoxelDensityProfile return false; } } + + bool IsAttributionScope(EBucket Bucket) + { + switch (Bucket) + { + case EBucket::GetDensityAt: + case EBucket::ApplyDisturbances: + case EBucket::PassageModifier: + case EBucket::PassageLandingAir: + case EBucket::PassageLandingFloor: + case EBucket::PassageLandingRoomFloor: + case EBucket::PassageTunnelAir: + case EBucket::PassageStructuralPosts: + case EBucket::TunnelCoreSupport: + case EBucket::TunnelCoreSdf: + case EBucket::TunnelCorePosts: + case EBucket::StructuralTail: + case EBucket::RoomGraphSource: + case EBucket::SdfCarve: + case EBucket::FusedEvaluator: + case EBucket::InterpretedOpStack: + case EBucket::OperatorBlock: + case EBucket::RoomGraphBuild: + case EBucket::RoomGraphSdf: + case EBucket::TunnelCoreWorld: + case EBucket::FusedDetail: + case EBucket::FusedWorm: + case EBucket::FusedStructural: + case EBucket::ClassifierTotal: + case EBucket::ClassifierIntervalProof: + case EBucket::ClassifierExactCore: + case EBucket::ClassifierExactFinal: + case EBucket::ClassifierRoomTail: + case EBucket::RuntimeStreamBuilding: + case EBucket::DensityPrologue: + case EBucket::DensityCore: + case EBucket::DensityDisturbances: + case EBucket::DensityStructuralPosts: + case EBucket::DensityBoundarySeal: + case EBucket::DensityDiffLayer: + case EBucket::DensityTail: + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherCellClassification: + case EBucket::MesherGradientNormals: + case EBucket::MesherVertexInterpolation: + case EBucket::MesherStreamBuilding: + case EBucket::MesherOther: + return true; + default: + return false; + } + } + + bool IsAttributionAlwaysTimed(EBucket Bucket) + { + switch (Bucket) + { + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherOther: + case EBucket::RoomGraphBuild: + return true; + default: + return false; + } + } + + uint64 ScaleSampledValue(uint64 Value, uint64 Calls, uint64 Samples) + { + if (Samples == 0 || Calls <= Samples) + { + return Value; + } + const double Scaled = static_cast(Value) + * static_cast(Calls) / static_cast(Samples); + return Scaled > static_cast(MAX_uint64) + ? MAX_uint64 + : static_cast(Scaled + 0.5); + } + + void AddClassifierStatsScaled( + FSnapshot::FClassifierStats& Destination, + const FSnapshot::FClassifierStats& Source, + uint64 Calls, + uint64 Samples) + { + Destination.RefineNodes += ScaleSampledValue(Source.RefineNodes, Calls, Samples); + Destination.StackBoxCalls += ScaleSampledValue(Source.StackBoxCalls, Calls, Samples); + Destination.WholeMixedNodes += ScaleSampledValue(Source.WholeMixedNodes, Calls, Samples); + Destination.WholeSolidNodes += ScaleSampledValue(Source.WholeSolidNodes, Calls, Samples); + Destination.WholeAirNodes += ScaleSampledValue(Source.WholeAirNodes, Calls, Samples); + Destination.NeedsFinalFieldNodes += ScaleSampledValue(Source.NeedsFinalFieldNodes, Calls, Samples); + Destination.SplitNodes += ScaleSampledValue(Source.SplitNodes, Calls, Samples); + Destination.MaxRefinementDepth = FMath::Max( + Destination.MaxRefinementDepth, Source.MaxRefinementDepth); + Destination.ExactCoreSamples += ScaleSampledValue(Source.ExactCoreSamples, Calls, Samples); + Destination.ExactFinalSamples += ScaleSampledValue(Source.ExactFinalSamples, Calls, Samples); + Destination.ExactCoreCacheHits += ScaleSampledValue(Source.ExactCoreCacheHits, Calls, Samples); + Destination.ExactFinalCacheHits += ScaleSampledValue(Source.ExactFinalCacheHits, Calls, Samples); + Destination.ExactCoreLeaves += ScaleSampledValue(Source.ExactCoreLeaves, Calls, Samples); + Destination.ExactFinalLeaves += ScaleSampledValue(Source.ExactFinalLeaves, Calls, Samples); + Destination.StackBoxCycles += ScaleSampledValue(Source.StackBoxCycles, Calls, Samples); + Destination.ExactCoreCycles += ScaleSampledValue(Source.ExactCoreCycles, Calls, Samples); + Destination.ExactFinalCycles += ScaleSampledValue(Source.ExactFinalCycles, Calls, Samples); + Destination.RoomTailQueries += ScaleSampledValue(Source.RoomTailQueries, Calls, Samples); + Destination.RoomTailEvaluated += ScaleSampledValue(Source.RoomTailEvaluated, Calls, Samples); + Destination.RoomTailCycles += ScaleSampledValue(Source.RoomTailCycles, Calls, Samples); + Destination.RoomPropagateCycles += ScaleSampledValue(Source.RoomPropagateCycles, Calls, Samples); + Destination.RoomExactPrimitiveCycles += ScaleSampledValue(Source.RoomExactPrimitiveCycles, Calls, Samples); + Destination.RoomCacheWindowCycles += ScaleSampledValue(Source.RoomCacheWindowCycles, Calls, Samples); + // These are topology descriptors, not per-call work counts. Keep the largest observed + // descriptor instead of multiplying one sampled graph description by the classifier + // call expansion factor. + Destination.RoomNumRooms = FMath::Max(Destination.RoomNumRooms, Source.RoomNumRooms); + Destination.RoomNumTunnels = FMath::Max(Destination.RoomNumTunnels, Source.RoomNumTunnels); + Destination.RoomNumRoomFloorJoins = FMath::Max( + Destination.RoomNumRoomFloorJoins, Source.RoomNumRoomFloorJoins); + Destination.RoomNumPits = FMath::Max(Destination.RoomNumPits, Source.RoomNumPits); + Destination.RoomNumChimneys = FMath::Max(Destination.RoomNumChimneys, Source.RoomNumChimneys); + } } void SetEnabled(bool bEnabled) @@ -184,6 +306,7 @@ namespace VoxelDensityProfile State->BlockStartCycles[Index] = 0; State->BlockActive[Index] = false; } + State->Classifier = FSnapshot::FClassifierStats(); State->TunnelCacheCapacityEntries = 0; State->TunnelCacheValidEntries = 0; State->TunnelCacheStaticBytes = 0; @@ -253,6 +376,14 @@ namespace VoxelDensityProfile Result.Counters[Index] += State->Counters[Index]; } + Result.Classifier.Calls += State->Classifier.Calls; + Result.Classifier.SampledCalls += State->Classifier.SampledCalls; + AddClassifierStatsScaled( + Result.Classifier, + State->Classifier, + State->Classifier.Calls, + State->Classifier.SampledCalls); + const uint64 TunnelWorkerBytes = State->TunnelCacheStaticBytes + State->TunnelCacheDynamicBytes; if (State->TunnelCacheCapacityEntries > 0) @@ -337,6 +468,19 @@ namespace VoxelDensityProfile { Result.Counters[Index] = State->Counters[Index]; } + Result.Classifier = State->Classifier; + if (State->Classifier.SampledCalls > 0) + { + FSnapshot::FClassifierStats Raw = State->Classifier; + Result.Classifier = FSnapshot::FClassifierStats(); + Result.Classifier.Calls = State->Classifier.Calls; + Result.Classifier.SampledCalls = State->Classifier.SampledCalls; + AddClassifierStatsScaled( + Result.Classifier, + Raw, + State->Classifier.Calls, + State->Classifier.SampledCalls); + } Result.TimerPairCycles = GetTimerPairCycles(); Result.SampleInterval = GetSampleInterval(); Result.Mode = GetMode(); @@ -345,6 +489,10 @@ namespace VoxelDensityProfile void AddCounter(ECounter Counter, uint64 Amount) { + if (!IsEnabledFast()) + { + return; + } const int32 Index = static_cast(Counter); if (Index >= 0 && Index < CounterCount) { @@ -366,6 +514,56 @@ namespace VoxelDensityProfile GetThreadState().Calls[Index] += 1; } + void RecordClassifierCall(bool bSampled) + { + FThreadState& State = GetThreadState(); + ++State.Classifier.Calls; + if (bSampled) + { + ++State.Classifier.SampledCalls; + } + ++State.Calls[ToIndex(EBucket::ClassifierTotal)]; + } + + void AddClassifierStats(const FSnapshot::FClassifierStats& Stats) + { + FThreadState& State = GetThreadState(); + State.Classifier.RefineNodes += Stats.RefineNodes; + State.Classifier.StackBoxCalls += Stats.StackBoxCalls; + State.Classifier.WholeMixedNodes += Stats.WholeMixedNodes; + State.Classifier.WholeSolidNodes += Stats.WholeSolidNodes; + State.Classifier.WholeAirNodes += Stats.WholeAirNodes; + State.Classifier.NeedsFinalFieldNodes += Stats.NeedsFinalFieldNodes; + State.Classifier.SplitNodes += Stats.SplitNodes; + State.Classifier.MaxRefinementDepth = FMath::Max( + State.Classifier.MaxRefinementDepth, Stats.MaxRefinementDepth); + State.Classifier.ExactCoreSamples += Stats.ExactCoreSamples; + State.Classifier.ExactFinalSamples += Stats.ExactFinalSamples; + State.Classifier.ExactCoreCacheHits += Stats.ExactCoreCacheHits; + State.Classifier.ExactFinalCacheHits += Stats.ExactFinalCacheHits; + State.Classifier.ExactCoreLeaves += Stats.ExactCoreLeaves; + State.Classifier.ExactFinalLeaves += Stats.ExactFinalLeaves; + State.Classifier.StackBoxCycles += Stats.StackBoxCycles; + State.Classifier.ExactCoreCycles += Stats.ExactCoreCycles; + State.Classifier.ExactFinalCycles += Stats.ExactFinalCycles; + State.Classifier.RoomTailQueries += Stats.RoomTailQueries; + State.Classifier.RoomTailEvaluated += Stats.RoomTailEvaluated; + State.Classifier.RoomTailCycles += Stats.RoomTailCycles; + State.Classifier.RoomPropagateCycles += Stats.RoomPropagateCycles; + State.Classifier.RoomExactPrimitiveCycles += Stats.RoomExactPrimitiveCycles; + State.Classifier.RoomCacheWindowCycles += Stats.RoomCacheWindowCycles; + State.Classifier.RoomNumRooms = FMath::Max( + State.Classifier.RoomNumRooms, Stats.RoomNumRooms); + State.Classifier.RoomNumTunnels = FMath::Max( + State.Classifier.RoomNumTunnels, Stats.RoomNumTunnels); + State.Classifier.RoomNumRoomFloorJoins = FMath::Max( + State.Classifier.RoomNumRoomFloorJoins, Stats.RoomNumRoomFloorJoins); + State.Classifier.RoomNumPits = FMath::Max( + State.Classifier.RoomNumPits, Stats.RoomNumPits); + State.Classifier.RoomNumChimneys = FMath::Max( + State.Classifier.RoomNumChimneys, Stats.RoomNumChimneys); + } + bool ShouldSample(EBucket Bucket) { if (!IsCycleTimingEnabled()) @@ -488,6 +686,12 @@ namespace VoxelDensityProfile case ECounter::OpBlockOperators: return TEXT("OpBlockOperators"); case ECounter::OpBlockActiveOperators: return TEXT("OpBlockActiveOperators"); case ECounter::OpBlockPrunedOperators: return TEXT("OpBlockPrunedOperators"); + case ECounter::MesherDensityGridBytes: return TEXT("MesherDensityGridBytes"); + case ECounter::MesherSharedGridReadBytes: return TEXT("MesherSharedGridReadBytes"); + case ECounter::MesherOutputArrayBytes: return TEXT("MesherOutputArrayBytes"); + case ECounter::MesherOutputAllocatedBytes: return TEXT("MesherOutputAllocatedBytes"); + case ECounter::OpBlockScratchBytes: return TEXT("OpBlockScratchBytes"); + case ECounter::OpBlockCopyBytes: return TEXT("OpBlockCopyBytes"); case ECounter::Count: break; } return TEXT("Unknown"); @@ -535,6 +739,21 @@ namespace VoxelDensityProfile { TEXT("BoundarySealOp"), EBucket::BoundarySealOp }, { TEXT("PassageCarveOp"), EBucket::PassageCarveOp }, { TEXT("XYEdgeSealOp"), EBucket::XYEdgeSealOp }, + { TEXT("FusedEvaluator"), EBucket::FusedEvaluator }, + { TEXT("InterpretedOpStack"),EBucket::InterpretedOpStack }, + { TEXT("OperatorBlock"), EBucket::OperatorBlock }, + { TEXT("RoomGraphBuild"), EBucket::RoomGraphBuild }, + { TEXT("RoomGraphSdf"), EBucket::RoomGraphSdf }, + { TEXT("TunnelCoreWorld"), EBucket::TunnelCoreWorld }, + { TEXT("FusedDetail"), EBucket::FusedDetail }, + { TEXT("FusedWorm"), EBucket::FusedWorm }, + { TEXT("FusedStructural"), EBucket::FusedStructural }, + { TEXT("ClassifierTotal"), EBucket::ClassifierTotal }, + { TEXT("ClassifierIntervalProof"), EBucket::ClassifierIntervalProof }, + { TEXT("ClassifierExactCore"), EBucket::ClassifierExactCore }, + { TEXT("ClassifierExactFinal"), EBucket::ClassifierExactFinal }, + { TEXT("ClassifierRoomTail"), EBucket::ClassifierRoomTail }, + { TEXT("RuntimeStreamBuilding"), EBucket::RuntimeStreamBuilding }, { TEXT("DensityPrologue"), EBucket::DensityPrologue }, { TEXT("DensityCore"), EBucket::DensityCore }, { TEXT("DensityDisturbances"), EBucket::DensityDisturbances }, @@ -596,6 +815,21 @@ namespace VoxelDensityProfile case EBucket::PassageCarveOp: return TEXT("PassageCarveOp"); case EBucket::XYEdgeSealOp: return TEXT("XYEdgeSealOp"); case EBucket::OtherOp: return TEXT("OtherOp"); + case EBucket::FusedEvaluator: return TEXT("FusedEvaluator"); + case EBucket::InterpretedOpStack:return TEXT("InterpretedOpStack"); + case EBucket::OperatorBlock: return TEXT("OperatorBlock"); + case EBucket::RoomGraphBuild: return TEXT("RoomGraphBuild"); + case EBucket::RoomGraphSdf: return TEXT("RoomGraphSdf"); + case EBucket::TunnelCoreWorld: return TEXT("TunnelCoreWorld"); + case EBucket::FusedDetail: return TEXT("FusedDetail"); + case EBucket::FusedWorm: return TEXT("FusedWorm"); + case EBucket::FusedStructural: return TEXT("FusedStructural"); + case EBucket::ClassifierTotal: return TEXT("ClassifierTotal"); + case EBucket::ClassifierIntervalProof:return TEXT("ClassifierIntervalProof"); + case EBucket::ClassifierExactCore:return TEXT("ClassifierExactCore"); + case EBucket::ClassifierExactFinal:return TEXT("ClassifierExactFinal"); + case EBucket::ClassifierRoomTail:return TEXT("ClassifierRoomTail"); + case EBucket::RuntimeStreamBuilding:return TEXT("RuntimeStreamBuilding"); case EBucket::DensityPrologue: return TEXT("DensityPrologue"); case EBucket::DensityCore: return TEXT("DensityCore"); case EBucket::DensityDisturbances:return TEXT("DensityDisturbances"); @@ -618,9 +852,14 @@ namespace VoxelDensityProfile FScopeToken BeginScope(EBucket Bucket) { FScopeToken Token; + if (!IsEnabledFast()) + { + return Token; + } const EMode Mode = GetMode(); - if (!IsEnabled() - || (Mode == EMode::Sampled && !IsSampledScopeEnabled(Bucket))) + const bool bSampledMode = Mode == EMode::Sampled || Mode == EMode::Attribution; + if ((Mode == EMode::Sampled && !IsSampledScopeEnabled(Bucket)) + || (Mode == EMode::Attribution && !IsAttributionScope(Bucket))) { return Token; } @@ -633,7 +872,7 @@ namespace VoxelDensityProfile // For the hot parent, amortise the timer over a deterministic block of calls. A timer // pair around one ~100 ns operation is mostly the timer; a pair around 64 calls is cheap // and estimates the parent from real elapsed work rather than instrumentation latency. - if (Mode == EMode::Sampled && Bucket == EBucket::GetDensityAt) + if (bSampledMode && Bucket == EBucket::GetDensityAt) { FThreadState& State = GetThreadState(); const int32 Index = ToIndex(Bucket); @@ -649,7 +888,11 @@ namespace VoxelDensityProfile State.BlockCallCount[Index] = 0; State.BlockStartCycles[Index] = FPlatformTime::Cycles64(); } - GScopeSampled = false; + // The selected call admits all nested component scopes. Every other call exits the + // inline FScopedTimer fast path before crossing into this translation unit. + GScopeSampled = bBlockBoundary; + GSampledScopeActive = bBlockBoundary; + Token.bSampled = bBlockBoundary; return Token; } @@ -660,13 +903,21 @@ namespace VoxelDensityProfile // density samples underneath them. const bool bContainerScope = Bucket == EBucket::MesherGenerateMesh || Bucket == EBucket::MesherDensityGrid; + const bool bInheritedAttributionSample = Mode == EMode::Attribution + && GSampledScopeActive; Token.bSampled = Mode == EMode::Full ? true - : (!GSampledScopeActive && ShouldSample(Bucket)); + : ((Mode == EMode::Attribution && IsAttributionAlwaysTimed(Bucket)) + ? true + : (bInheritedAttributionSample + ? true + : (Mode == EMode::Attribution && IsAttributionScope(Bucket) + ? ShouldSample(Bucket) + : (!GSampledScopeActive && ShouldSample(Bucket))))); GScopeSampled = Token.bSampled; if (Token.bSampled) { - if (Mode == EMode::Sampled && !bContainerScope) + if ((Mode == EMode::Sampled || Mode == EMode::Attribution) && !bContainerScope) { GSampledScopeActive = true; } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index cd2aa96..57922c4 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -330,7 +330,15 @@ struct FVoxelDensityBlockSession const int32 BlockSizeY = MaxY - MinY + 1; const int32 BlockSizeZ = MaxZ - MinZ + 1; const int32 BlockCount = BlockSizeX * BlockSizeY * BlockSizeZ; + VoxelDensityProfile::FScopedTimer OperatorBlockTimer( + VoxelDensityProfile::EBucket::OperatorBlock); BlockScratch.SetNum(BlockCount); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockScratchBytes, + static_cast(BlockCount) * sizeof(FVoxelOpSample)); + } for (FVoxelOpSample& Sample : BlockScratch) { Sample = FVoxelOpSample(); @@ -371,6 +379,13 @@ struct FVoxelDensityBlockSession } } } + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockCopyBytes, + static_cast(BlockCount) + * (sizeof(FVoxelOpSample) + sizeof(uint8))); + } if (VoxelDensityProfile::AreCountersEnabled()) { @@ -2053,6 +2068,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // interpreted stack, plus the source's structural hand-off, without allocating // FVoxelOpSample or traversing the graph one operator at a time for this voxel. bUsedFusedEvaluator = true; + VoxelDensityProfile::FScopedTimer FusedEvaluatorTimer( + VoxelDensityProfile::EBucket::FusedEvaluator); Result = GetDensityWithParams( WorldX, WorldY, WorldZ, CP_Tunnel, CP_TunnelFP, LayoutVersion, /*bApplyLegacyStructuralPosts=*/false, @@ -2074,14 +2091,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // must not construct a per-sample FVoxelOpSample merely because this compatibility // path still needs one when the block session is enabled. FVoxelOpSample BlockCoreSample; - bUsedOpBlockSample = GVoxelDensityBlockSession.FillChunk( - *ActiveOpStack, - ChunkCoord, - FIntVector( - FMath::RoundToInt(WorldX), - FMath::RoundToInt(WorldY), - FMath::RoundToInt(WorldZ)), - BlockCoreSample); + { + VoxelDensityProfile::FScopedTimer OperatorBlockTimer( + VoxelDensityProfile::EBucket::OperatorBlock); + bUsedOpBlockSample = GVoxelDensityBlockSession.FillChunk( + *ActiveOpStack, + ChunkCoord, + FIntVector( + FMath::RoundToInt(WorldX), + FMath::RoundToInt(WorldY), + FMath::RoundToInt(WorldZ)), + BlockCoreSample); + } if (bUsedOpBlockSample) { BlockDensity = BlockCoreSample.Density; @@ -2093,9 +2114,16 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } if (!bUsedFusedEvaluator) { - Result = bUsedOpBlockSample - ? -BlockDensity - : ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ); + if (bUsedOpBlockSample) + { + Result = -BlockDensity; + } + else + { + VoxelDensityProfile::FScopedTimer InterpretedOpStackTimer( + VoxelDensityProfile::EBucket::InterpretedOpStack); + Result = ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ); + } } } else switch (CP_GenType) @@ -2221,6 +2249,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // calls use the same landing geometry already evaluated by the legacy and op-stack post. VoxelDensityProfile::FScopedTimer DensityStructuralPostsTimer( VoxelDensityProfile::EBucket::DensityStructuralPosts); + VoxelDensityProfile::FScopedTimer StructuralTailTimer( + VoxelDensityProfile::EBucket::StructuralTail); float LandingBaseDensity = CP_Dist.BaseDensity; #if WITH_EDITOR // A composer parent can deliberately use a different structural base than the authored @@ -2288,17 +2318,29 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } // Origin rooms are structural too. Reassert their air before either support writer so a // bridge/ridge cannot plug a landing, while the two floor writers remain last. - VF_ApplyOriginLandingAirMC( - Result, WorldX, WorldY, WorldZ, - CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, - CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - VF_ApplyOriginLandingFloorMC( - Result, WorldX, WorldY, WorldZ, - CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, - CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageStructuralPostsMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity, - CP_Dist.BoundarySealThickness, bProtectAuthoredTunnelFloor); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingAir); + VF_ApplyOriginLandingAirMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + } + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + } + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageStructuralPosts); + StrateManager->ApplyPassageStructuralPostsMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity, + CP_Dist.BoundarySealThickness, bProtectAuthoredTunnelFloor); + } // Disturbance features are authored as a generic MC-space post and may add a ridge or // bridge over a graph tunnel. Reassert the native cached tunnel core here, after every // solid floor writer but before the global XY seal. This cache is built once per chunk, @@ -2393,22 +2435,39 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // backstop is allowed to reopen the tunnel, but the landing's proved support floor // must own the final floor band; otherwise the graph post can erase the only support // surface at the mouth and leave the player-fit graph with a disconnected pocket. - VF_ApplyOriginLandingFloorMC( - Result, WorldX, WorldY, WorldZ, - CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, - CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageLandingFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); - StrateManager->ApplyPassageLandingRoomFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + } + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + StrateManager->ApplyPassageLandingFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingRoomFloor); + StrateManager->ApplyPassageLandingRoomFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } } } // Inter-strate passages own the same final D-floor contract as graph tunnels. This is // composed from the immutable construction-time profile after generic writers and the // graph overlap, so the legacy passage support slab can be disabled without losing the // floor to a disturbance refill. - StrateManager->ApplyPassageNativeFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + StrateManager->ApplyPassageNativeFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } + StructuralTailTimer.End(); DensityStructuralPostsTimer.End(); } else @@ -2434,7 +2493,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // below and are deliberately still the user override path. VoxelDensityProfile::FScopedTimer DensityBoundarySealTimer( VoxelDensityProfile::EBucket::DensityBoundarySeal); - VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::XYEdgeSealOp); + VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); + } DensityBoundarySealTimer.End(); //========================================================================= @@ -2879,6 +2942,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo : VoxelDensityProfile::ECounter::FusedTunnelDetailSkipped); } + VoxelDensityProfile::FScopedTimer FusedDetailTimer( + VoxelDensityProfile::EBucket::FusedDetail); + //========================================================================= // STEP 4b: SURFACE ROUGHNESS (volumetric, SDF-based) //========================================================================= @@ -3502,6 +3568,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo } } // end bNearCaveSurface (terrain ops) + FusedDetailTimer.End(); // Steps 4e / 4f (pits and chimneys) are now handled by the CaveSDF SmoothMin // block inserted above (between EvaluateSDFCached and the CarveFactor section). @@ -3524,6 +3591,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // already computed by Step 4 — free): full strength at the network, smooth fade to zero // at Range. Worms become braids/shortcuts hugging the cave system; no isolated speckle. // Range = 0 → unmasked legacy behaviour. Bonus: fully-masked voxels skip both Perlins. + VoxelDensityProfile::FScopedTimer FusedWormTimer( + VoxelDensityProfile::EBucket::FusedWorm); if (Params.WormStrength > 0.0f && Params.WormThreshold > 0.0f) { float NetworkMask = 1.0f; @@ -3570,16 +3639,28 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo } } + FusedWormTimer.End(); + //========================================================================= // STEP 6: STRATE BOUNDARY SEAL (haut + bas) //========================================================================= - ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, - Params.StrateTopWorldZ, Params.StrateBottomWorldZ, - Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + VoxelDensityProfile::FScopedTimer FusedStructuralTimer( + VoxelDensityProfile::EBucket::FusedStructural); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::OriginSpineOp); + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + } - ApplyBoundarySeal(Density, WorldZ, - Params.StrateTopWorldZ, Params.StrateBottomWorldZ, - Params.BoundarySealThickness, Params.BaseDensity); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::BoundarySealOp); + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + } //========================================================================= // STEP 7: INTER-STRATE PASSAGES (perce le seal) @@ -3588,6 +3669,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo { if (bApplyLegacyStructuralPosts) { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageModifier); StrateManager->ApplyPassageModifier( Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); @@ -3597,6 +3680,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // GetDensityAt owns one common MC-space structural tail after either the legacy or // operator-stack branch. Keep the actual tube carve here, but do not repeat its // landing air/floor/tunnel-air posts before the shared disturbance/post sequence. + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageCarveOp); StrateManager->ApplyPassageCarvingOnly( Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); @@ -3646,6 +3731,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); } + FusedStructuralTimer.End(); + // Convention MC: négatif = solide, positif = air. // La logique interne utilise positif = solide (plus lisible), donc on négate. return -Density; diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 300bc9f..1cc1724 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -300,8 +300,96 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::MesherBlockTests); } + const bool bAttributeClassifier = + VoxelDensityProfile::GetMode() + == VoxelDensityProfile::EMode::Attribution; + const bool bSampleClassifier = bAttributeClassifier + && VoxelDensityProfile::ShouldSample( + VoxelDensityProfile::EBucket::ClassifierTotal); + const uint64 ClassifierStartCycles = bSampleClassifier + ? FPlatformTime::Cycles64() : 0; + FVoxelTileClassificationStats ClassifierStats; + if (bAttributeClassifier) + { + VoxelDensityProfile::RecordClassifierCall(bSampleClassifier); + } const EVoxelTileClass Verdict = Generator->ClassifyTile( - BlockOrigin, Step, BlockCells); + BlockOrigin, Step, BlockCells, + bSampleClassifier ? &ClassifierStats : nullptr); + if (bSampleClassifier) + { + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierTotal, + FPlatformTime::Cycles64() - ClassifierStartCycles); + VoxelDensityProfile::FSnapshot::FClassifierStats ProfileStats; + ProfileStats.RefineNodes = ClassifierStats.RefineNodes; + ProfileStats.StackBoxCalls = ClassifierStats.StackBoxCalls; + ProfileStats.WholeMixedNodes = ClassifierStats.WholeMixedNodes; + ProfileStats.WholeSolidNodes = ClassifierStats.WholeSolidNodes; + ProfileStats.WholeAirNodes = ClassifierStats.WholeAirNodes; + ProfileStats.NeedsFinalFieldNodes = ClassifierStats.NeedsFinalFieldNodes; + ProfileStats.SplitNodes = ClassifierStats.SplitNodes; + ProfileStats.MaxRefinementDepth = ClassifierStats.MaxRefinementDepth; + ProfileStats.ExactCoreSamples = ClassifierStats.ExactCoreSamples; + ProfileStats.ExactFinalSamples = ClassifierStats.ExactFinalSamples; + ProfileStats.ExactCoreCacheHits = ClassifierStats.ExactCoreCacheHits; + ProfileStats.ExactFinalCacheHits = ClassifierStats.ExactFinalCacheHits; + ProfileStats.ExactCoreLeaves = ClassifierStats.ExactCoreLeaves; + ProfileStats.ExactFinalLeaves = ClassifierStats.ExactFinalLeaves; + ProfileStats.StackBoxCycles = ClassifierStats.StackBoxCycles; + ProfileStats.ExactCoreCycles = ClassifierStats.ExactCoreCycles; + ProfileStats.ExactFinalCycles = ClassifierStats.ExactFinalCycles; + ProfileStats.RoomTailQueries = ClassifierStats.RoomTailQueries; + ProfileStats.RoomTailEvaluated = ClassifierStats.RoomTailEvaluated; + ProfileStats.RoomTailCycles = ClassifierStats.RoomTailCycles; + ProfileStats.RoomPropagateCycles = ClassifierStats.RoomPropagateCycles; + ProfileStats.RoomExactPrimitiveCycles = ClassifierStats.RoomExactPrimitiveCycles; + ProfileStats.RoomCacheWindowCycles = ClassifierStats.RoomCacheWindowCycles; + ProfileStats.RoomNumRooms = FMath::Max(ClassifierStats.RoomNumRooms, 0); + ProfileStats.RoomNumTunnels = FMath::Max(ClassifierStats.RoomNumTunnels, 0); + ProfileStats.RoomNumRoomFloorJoins = FMath::Max( + ClassifierStats.RoomNumRoomFloorJoins, 0); + ProfileStats.RoomNumPits = FMath::Max(ClassifierStats.RoomNumPits, 0); + ProfileStats.RoomNumChimneys = FMath::Max(ClassifierStats.RoomNumChimneys, 0); + VoxelDensityProfile::AddClassifierStats(ProfileStats); + + if (ClassifierStats.StackBoxCalls > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierIntervalProof, + 0, ClassifierStats.StackBoxCalls); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierIntervalProof, + ClassifierStats.StackBoxCycles); + } + if (ClassifierStats.ExactCoreLeaves > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactCore, + 0, ClassifierStats.ExactCoreLeaves); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactCore, + ClassifierStats.ExactCoreCycles); + } + if (ClassifierStats.ExactFinalLeaves > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactFinal, + 0, ClassifierStats.ExactFinalLeaves); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactFinal, + ClassifierStats.ExactFinalCycles); + } + if (ClassifierStats.RoomTailQueries > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierRoomTail, + 0, ClassifierStats.RoomTailQueries); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierRoomTail, + ClassifierStats.RoomTailCycles); + } + } if (Verdict == EVoxelTileClass::Mixed) { MixedBlockIndices.Add( @@ -490,6 +578,21 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } } + if (VoxelDensityProfile::AreCountersEnabled()) + { + const uint64 GridSampleCount = static_cast(GzHi - GzLo + 1) + * static_cast(MDim) + * static_cast(MDim); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherDensityGridBytes, + GridSampleCount * sizeof(float)); + if (bUseSharedDensityGrid) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherSharedGridReadBytes, + GridSampleCount * sizeof(float)); + } + } if (bUseOperatorBlock) { Generator->EndDensityBlock(); @@ -835,6 +938,26 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, MeshData.Triangles.Append(CapTris); MeshData.NumCeilingTriangles = CapTris.Num() / 3; + if (VoxelDensityProfile::AreCountersEnabled()) + { + const uint64 OutputArrayBytes = static_cast(MeshData.Vertices.Num()) * sizeof(FVector) + + static_cast(MeshData.Normals.Num()) * sizeof(FVector) + + static_cast(MeshData.UVs.Num()) * sizeof(FVector2D) + + static_cast(MeshData.Colors.Num()) * sizeof(FColor) + + static_cast(MeshData.Triangles.Num()) * sizeof(int32); + const uint64 OutputAllocatedBytes = static_cast(MeshData.Vertices.GetAllocatedSize()) + + static_cast(MeshData.Normals.GetAllocatedSize()) + + static_cast(MeshData.UVs.GetAllocatedSize()) + + static_cast(MeshData.Colors.GetAllocatedSize()) + + static_cast(MeshData.Triangles.GetAllocatedSize()); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherOutputArrayBytes, + OutputArrayBytes); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherOutputAllocatedBytes, + OutputAllocatedBytes); + } + return MeshData; } diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 7436809..6369f78 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -68,6 +68,11 @@ namespace TEXT("voxel.ProfileDensityFull"), GVoxelForgeProfileDensityFull, TEXT("Collect full per-operator profiling for the game path.")); + int32 GVoxelForgePerfAttribution = 0; + FAutoConsoleVariableRef CVarVoxelForgePerfAttribution( + TEXT("voxel.PerfAttribution"), + GVoxelForgePerfAttribution, + TEXT("Collect low-overhead component attribution for the game path.")); // Startup command-line cvars can arrive before the game world has spawned AVoxelWorld (or // before its first tiles have become visible). Keep the diagnostic edit pending until Tick has @@ -1038,6 +1043,7 @@ void AVoxelWorld::BeginPlay() // the default mode because of startup ordering. int32 CommandLineProfile = GVoxelForgeProfileDensity; int32 CommandLineProfileFull = GVoxelForgeProfileDensityFull; + int32 CommandLinePerfAttribution = GVoxelForgePerfAttribution; if (FParse::Value(FCommandLine::Get(), TEXT("voxel.ProfileDensity="), CommandLineProfile)) { GVoxelForgeProfileDensity = CommandLineProfile; @@ -1046,8 +1052,21 @@ void AVoxelWorld::BeginPlay() { GVoxelForgeProfileDensityFull = CommandLineProfileFull; } + if (FParse::Value(FCommandLine::Get(), TEXT("voxel.PerfAttribution="), CommandLinePerfAttribution)) + { + GVoxelForgePerfAttribution = CommandLinePerfAttribution; + } - if (GVoxelForgeProfileDensityFull != 0) + if (GVoxelForgePerfAttribution != 0) + { + VoxelDensityProfile::SetMode( + VoxelDensityProfile::EMode::Attribution, + VoxelDensityProfile::DefaultSampleInterval); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] game_mode=attribution sample_interval=%u"), + VoxelDensityProfile::DefaultSampleInterval); + } + else if (GVoxelForgeProfileDensityFull != 0) { VoxelDensityProfile::SetMode( VoxelDensityProfile::EMode::Full, @@ -2084,7 +2103,8 @@ void AVoxelWorld::LogStreamingLatencySummary() const const double TravelP95Metres = RequestP95 * static_cast(PeakObservedPawnSpeedCmPerSecond) / 100.0; const bool bDiagnosticsEnabled = GVoxelForgeProfileTileGeneration != 0 - || VoxelForgeStartupTrace::IsActive(); + || VoxelForgeStartupTrace::IsActive() + || VoxelDensityProfile::GetMode() != VoxelDensityProfile::EMode::Disabled; UE_LOG(LogTemp, Display, TEXT("[VoxelForgeStreamingLatency] mode=%s diagnostics=%s lod0_ready_samples=%d " "request_to_ready_s[p50=%.6f p95=%.6f max=%.6f] " @@ -3651,7 +3671,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector const std::atomic* ObsoleteFlag) { const bool bLogTileProfile = GVoxelForgeProfileTileGeneration != 0; - const bool bMeasureTile = bLogTileProfile || VoxelForgeStartupTrace::IsActive(); + const bool bMeasureTile = bLogTileProfile + || VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Attribution + || VoxelForgeStartupTrace::IsActive(); const double TileStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; @@ -3919,6 +3941,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams); const double StreamStartSeconds = bMeasureTile ? FPlatformTime::Seconds() : 0.0; Result.Streams = MakeShared(); + VoxelDensityProfile::FScopedTimer RuntimeStreamTimer( + VoxelDensityProfile::EBucket::RuntimeStreamBuilding); BuildTileStreamSet(*Result.Streams, MeshData); StreamSeconds = bMeasureTile ? FPlatformTime::Seconds() - StreamStartSeconds : 0.0; if (ShouldAbortWork(ObsoleteFlag)) diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index 6719989..e268d09 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -15,12 +15,15 @@ namespace VoxelDensityProfile { Disabled, Sampled, + // Low-overhead attribution: keeps the parent and stage ledger trustworthy, samples + // hot component scopes, and collects classifier detail without the old full-scope storm. + Attribution, Full, }; // Count-based sampling is deterministic with respect to the profiler's own counters, but the // report is intentionally diagnostic only. It never participates in density or mesh output. - constexpr uint32 DefaultSampleInterval = 64; + constexpr uint32 DefaultSampleInterval = 128; enum class EBucket : uint8 { @@ -56,6 +59,23 @@ namespace VoxelDensityProfile PassageCarveOp, XYEdgeSealOp, OtherOp, + // Attribution components. These are nested diagnostics; the non-overlapping phase + // buckets below remain the density accounting ledger. + FusedEvaluator, + InterpretedOpStack, + OperatorBlock, + RoomGraphBuild, + RoomGraphSdf, + TunnelCoreWorld, + FusedDetail, + FusedWorm, + FusedStructural, + ClassifierTotal, + ClassifierIntervalProof, + ClassifierExactCore, + ClassifierExactFinal, + ClassifierRoomTail, + RuntimeStreamBuilding, // Non-overlapping top-level phases. The named operation buckets above are intentionally // nested diagnostics; these phase buckets are the accounting ledger that must add up to // GetDensityAt without double-counting a stack op inside its enclosing phase. @@ -128,11 +148,24 @@ namespace VoxelDensityProfile OpBlockOperators, OpBlockActiveOperators, OpBlockPrunedOperators, + MesherDensityGridBytes, + MesherSharedGridReadBytes, + MesherOutputArrayBytes, + MesherOutputAllocatedBytes, + OpBlockScratchBytes, + OpBlockCopyBytes, Count }; constexpr int32 CounterCount = static_cast(ECounter::Count); + // Export the one fast-path bit so callers in the editor module can skip instrumentation + // without crossing the DLL boundary for every scoped timer or counter. It is diagnostic + // state only; it never participates in density or mesh decisions. + VOXELFORGE_API extern std::atomic GEnabled; + VOXELFORGE_API extern std::atomic GMode; + inline thread_local bool GSampledScopeActive = false; + // Resident allocator-backed bytes for one worker's generation cache family. The profiler // aggregates this by worker; the fields intentionally describe allocations owned by the // cache, not the inline size of the enclosing C++ object. SlotStorageBytes is the one @@ -193,6 +226,44 @@ namespace VoxelDensityProfile uint32 ActiveWorkers[BucketCount]{}; uint64 Counters[CounterCount]{}; + // Classifier detail is sampled separately from the timer buckets. Calls is the exact + // number of classifier invocations; the remaining fields are worker-local samples + // expanded at Snapshot() using Calls / SampledCalls. + struct FClassifierStats + { + uint64 Calls = 0; + uint64 SampledCalls = 0; + uint64 RefineNodes = 0; + uint64 StackBoxCalls = 0; + uint64 WholeMixedNodes = 0; + uint64 WholeSolidNodes = 0; + uint64 WholeAirNodes = 0; + uint64 NeedsFinalFieldNodes = 0; + uint64 SplitNodes = 0; + uint64 MaxRefinementDepth = 0; + uint64 ExactCoreSamples = 0; + uint64 ExactFinalSamples = 0; + uint64 ExactCoreCacheHits = 0; + uint64 ExactFinalCacheHits = 0; + uint64 ExactCoreLeaves = 0; + uint64 ExactFinalLeaves = 0; + uint64 StackBoxCycles = 0; + uint64 ExactCoreCycles = 0; + uint64 ExactFinalCycles = 0; + uint64 RoomTailQueries = 0; + uint64 RoomTailEvaluated = 0; + uint64 RoomTailCycles = 0; + uint64 RoomPropagateCycles = 0; + uint64 RoomExactPrimitiveCycles = 0; + uint64 RoomCacheWindowCycles = 0; + uint64 RoomNumRooms = 0; + uint64 RoomNumTunnels = 0; + uint64 RoomNumRoomFloorJoins = 0; + uint64 RoomNumPits = 0; + uint64 RoomNumChimneys = 0; + }; + FClassifierStats Classifier; + uint64 TimerPairCycles = 0; uint32 SampleInterval = DefaultSampleInterval; EMode Mode = EMode::Disabled; @@ -225,9 +296,47 @@ namespace VoxelDensityProfile VOXELFORGE_API void SetEnabled(bool bEnabled); VOXELFORGE_API void SetMode(EMode Mode, uint32 SampleInterval = DefaultSampleInterval); - // Coarse counters are intentionally always collected, even when cycle timing is disabled. - // They are the low-cost diagnostic path; only cycle timing is opt-in. - FORCEINLINE bool AreCountersEnabled() { return true; } + // Counters are collected only while a diagnostic mode is enabled. In the ordinary clean + // path this is a single relaxed load, so the measurement hooks stay dormant. + FORCEINLINE bool IsEnabledFast() + { + return GEnabled.load(std::memory_order_relaxed); + } + FORCEINLINE bool AreCountersEnabled() { return IsEnabledFast(); } + FORCEINLINE bool ShouldProbeScopeFast(EBucket Bucket) + { + const EMode Mode = GMode.load(std::memory_order_relaxed); + if (Mode == EMode::Full) + { + return true; + } + if (Mode == EMode::Sampled) + { + return Bucket == EBucket::GetDensityAt + || Bucket == EBucket::MesherGenerateMesh + || Bucket == EBucket::MesherDensityGrid + || Bucket == EBucket::MesherOther; + } + if (Mode == EMode::Attribution) + { + switch (Bucket) + { + case EBucket::GetDensityAt: + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherCellClassification: + case EBucket::MesherGradientNormals: + case EBucket::MesherVertexInterpolation: + case EBucket::MesherStreamBuilding: + case EBucket::MesherOther: + case EBucket::RoomGraphBuild: + return true; + default: + return GSampledScopeActive; + } + } + return false; + } VOXELFORGE_API bool IsEnabled(); VOXELFORGE_API bool IsCycleTimingEnabled(); VOXELFORGE_API EMode GetMode(); @@ -242,6 +351,8 @@ namespace VoxelDensityProfile VOXELFORGE_API void AddCounter(ECounter Counter, uint64 Amount = 1); VOXELFORGE_API void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls = 1); VOXELFORGE_API void RecordCall(EBucket Bucket); + VOXELFORGE_API void RecordClassifierCall(bool bSampled); + VOXELFORGE_API void AddClassifierStats(const FSnapshot::FClassifierStats& Stats); VOXELFORGE_API bool ShouldSample(EBucket Bucket); VOXELFORGE_API void AddSampledMeasurement(EBucket Bucket, uint64 RawCycles); @@ -279,15 +390,24 @@ namespace VoxelDensityProfile public: explicit FScopedTimer(EBucket InBucket) : Bucket(InBucket) - , Token(BeginScope(InBucket)) { + if (IsEnabledFast() && ShouldProbeScopeFast(InBucket)) + { + Token = BeginScope(InBucket); + } } explicit FScopedTimer(const TCHAR* InName) : Bucket(EBucket::OtherOp) { - Bucket = BucketFromName(InName); - Token = BeginScope(Bucket); + if (IsEnabledFast()) + { + Bucket = BucketFromName(InName); + if (ShouldProbeScopeFast(Bucket)) + { + Token = BeginScope(Bucket); + } + } } ~FScopedTimer(); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 8d38816..56f725d 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -266,6 +266,7 @@ struct FExploreArguments bool bUseOperatorStack = true; bool bProfileDensity = false; bool bProfileDensityFull = false; + bool bPerfAttribution = false; bool bProfileLod = false; bool bOpBounds = false; bool bFailureFocusRender = false; @@ -313,6 +314,11 @@ struct FExploreArguments bool bRenderCameraOverride = false; FVector RenderCamera = FVector::ZeroVector; + bool IsDensityProfilingEnabled() const + { + return bProfileDensity || bPerfAttribution; + } + FString CanonicalModes() const { FString Result; @@ -358,6 +364,7 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutArguments.bProfileDensity = FParse::Param(*Params, TEXT("profiledensity")); OutArguments.bProfileDensityFull = FParse::Param(*Params, TEXT("profiledensityfull")); OutArguments.bProfileDensity |= OutArguments.bProfileDensityFull; + OutArguments.bPerfAttribution = FParse::Param(*Params, TEXT("perfattribution")); OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds")); FParse::Value(*Params, TEXT("out="), OutText); @@ -741,6 +748,10 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr VoxelDensityProfile::EMode ExploreProfileMode(const FExploreArguments& Arguments) { + if (Arguments.bPerfAttribution) + { + return VoxelDensityProfile::EMode::Attribution; + } return Arguments.bProfileDensityFull ? VoxelDensityProfile::EMode::Full : VoxelDensityProfile::EMode::Sampled; @@ -850,6 +861,14 @@ struct FExploreWorld int32 ExploreMeshTileCount = 0; int32 ExploreMeshTilesCompleted = 0; double ExploreMeshSeconds = 0.0; + // Exact wall-clock partition of ExploreMeshSeconds. The first three fields end at the + // parallel tile launch; merge closes the mesh clock. Remainder is intentionally retained + // instead of rounding the ledger to 100%. + double ExploreMeshPreparationSeconds = 0.0; + double ExploreMeshPreTileSetupSeconds = 0.0; + double ExploreMeshMergeSeconds = 0.0; + double ExploreMeshAccountingRemainderSeconds = 0.0; + double ExploreMeshHashSeconds = 0.0; double ExploreDensityGridSeconds = 0.0; int64 ExploreDensityGridTotalSamples = 0; int64 ExploreDensityGridUniqueSamples = 0; @@ -1235,6 +1254,11 @@ struct FExploreExportOutput FExploreMeshMetrics SurfaceMetrics; double MeshSeconds = 0.0; double MeshUsPerVoxel = 0.0; + double MeasureSeconds = 0.0; + double ObjWriteSeconds = 0.0; + double ManifestSeconds = 0.0; + double ExportWallSeconds = 0.0; + double ExportAccountingRemainderSeconds = 0.0; FString GeometryHash; bool bTruncated = false; }; @@ -1634,6 +1658,11 @@ bool EnsureExploreMesh( FMath::FloorToInt(0.5f * static_cast( World.TargetBottomWorldZ + World.TargetTopWorldZ))); World.ExploreMeshSeconds = 0.0; + World.ExploreMeshPreparationSeconds = 0.0; + World.ExploreMeshPreTileSetupSeconds = 0.0; + World.ExploreMeshMergeSeconds = 0.0; + World.ExploreMeshAccountingRemainderSeconds = 0.0; + World.ExploreMeshHashSeconds = 0.0; World.ExploreDensityGridSeconds = 0.0; World.ExploreDensityGridTotalSamples = 0; World.ExploreDensityGridUniqueSamples = 0; @@ -1694,6 +1723,7 @@ bool EnsureExploreMesh( World.ExploreDensityGridDuplicateSamplesAfter = Arguments.bReuseDensityGrid ? 0 : World.ExploreDensityGridDuplicateSamplesBefore; + const double DensityStageStartSeconds = FPlatformTime::Seconds(); if (Arguments.bReuseDensityGrid) { const double DensityGridStartSeconds = FPlatformTime::Seconds(); @@ -1780,6 +1810,8 @@ bool EnsureExploreMesh( World.Mesher->SetSharedDensityGrid(&World.ExploreSharedDensityGrid); World.bExploreDensityGridReused = true; } + const double DensityStageEndSeconds = FPlatformTime::Seconds(); + World.ExploreMeshPreparationSeconds = DensityStageStartSeconds - MeshStartSeconds; // Generate each tile through the canonical mesher on worker threads, then append strictly in // Z/Y/X order. The mesher's scratch buffers and the generator's hot caches are thread-local; @@ -1802,6 +1834,7 @@ bool EnsureExploreMesh( std::atomic bTileWorkCancelled(false); const double TileWorkDeadline = Budget.StartSeconds + Budget.LimitSeconds - 0.25; const double ParallelStartSeconds = FPlatformTime::Seconds(); + World.ExploreMeshPreTileSetupSeconds = ParallelStartSeconds - DensityStageEndSeconds; ParallelFor( TEXT("VoxelForgeExploreMeshTiles"), World.ExploreMeshTileCount, @@ -1925,9 +1958,20 @@ bool EnsureExploreMesh( return false; } - World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds; + const double MeshEndSeconds = FPlatformTime::Seconds(); + World.ExploreMeshSeconds = MeshEndSeconds - MeshStartSeconds; + World.ExploreMeshMergeSeconds = MeshEndSeconds - ParallelEndSeconds; + const double MeshStageSumSeconds = World.ExploreMeshPreparationSeconds + + World.ExploreDensityGridSeconds + + World.ExploreMeshPreTileSetupSeconds + + World.ExploreMeshTaskWallSeconds + + World.ExploreMeshMergeSeconds; + World.ExploreMeshAccountingRemainderSeconds = + World.ExploreMeshSeconds - MeshStageSumSeconds; World.bExploreMeshComplete = true; + const double GeometryHashStartSeconds = FPlatformTime::Seconds(); World.ExploreGeometryHash = ComputeGeometryHash(World.ExploreMesh); + World.ExploreMeshHashSeconds = FPlatformTime::Seconds() - GeometryHashStartSeconds; OutError.Reset(); return true; } @@ -4021,7 +4065,7 @@ FString BuildManifestJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 6); + Writer->WriteValue(TEXT("schema_version"), 7); Writer->WriteValue(TEXT("format"), TEXT("OBJ")); Writer->WriteValue(TEXT("mesh_file"), Export.MeshFileName); Writer->WriteValue(TEXT("mesh_positions_units"), TEXT("metres")); @@ -4126,6 +4170,7 @@ bool RunExport( FExploreBudget& Budget) { OutOutput = FExploreExportOutput(); + const double ExportStartSeconds = FPlatformTime::Seconds(); OutOutput.RegionSize = Arguments.ExportSize; OutOutput.EstimatedWorkingBytes = EstimateExportWorkingBytes(Arguments.ExportSize); if (OutOutput.EstimatedWorkingBytes > OutOutput.WorkingMemoryCapBytes) @@ -4163,6 +4208,7 @@ bool RunExport( OutOutput.RefusalReason = FString::Printf(TEXT("Could not create OBJ '%s'."), *MeshPath); return false; } + const double ObjWriteStartSeconds = FPlatformTime::Seconds(); if (!WriteObjHeader(*Archive)) { OutOutput.Status = TEXT("error"); @@ -4170,12 +4216,14 @@ bool RunExport( return false; } + const double MeasureStartSeconds = FPlatformTime::Seconds(); if (!MeasureExploreMesh(World.ExploreMesh, OutOutput.SurfaceMetrics, Error)) { OutOutput.Status = TEXT("error"); OutOutput.RefusalReason = Error; return false; } + OutOutput.MeasureSeconds = FPlatformTime::Seconds() - MeasureStartSeconds; OutOutput.MeshArrayBytes = static_cast(World.ExploreMesh.Vertices.Num()) * sizeof(FVector) + static_cast(World.ExploreMesh.Normals.Num()) * sizeof(FVector) + static_cast(World.ExploreMesh.UVs.Num()) * sizeof(FVector2D) @@ -4221,6 +4269,7 @@ bool RunExport( OutOutput.RefusalReason = TEXT("OBJ was written but its file size could not be read."); return false; } + OutOutput.ObjWriteSeconds = FPlatformTime::Seconds() - ObjWriteStartSeconds; OutOutput.bTruncated = Budget.bTruncated || !World.bExploreMeshComplete @@ -4235,6 +4284,7 @@ bool RunExport( : TEXT("The wall-clock budget stopped export after writing a partial mesh."); } + const double ManifestStartSeconds = FPlatformTime::Seconds(); const FString ManifestJson = BuildManifestJson(Arguments, OutOutput); const FString ManifestJsonRepeat = BuildManifestJson(Arguments, OutOutput); if (ManifestJson.IsEmpty() || ManifestJsonRepeat.IsEmpty()) @@ -4258,6 +4308,13 @@ bool RunExport( OutOutput.RefusalReason = TEXT("Could not write export manifest.json."); return false; } + OutOutput.ManifestSeconds = FPlatformTime::Seconds() - ManifestStartSeconds; + OutOutput.ExportWallSeconds = FPlatformTime::Seconds() - ExportStartSeconds; + OutOutput.ExportAccountingRemainderSeconds = OutOutput.ExportWallSeconds + - (World.ExploreMeshSeconds + + World.ExploreMeshHashSeconds + + OutOutput.ObjWriteSeconds + + OutOutput.ManifestSeconds); if (!OutOutput.bTruncated) { OutOutput.Status = TEXT("ok"); @@ -4348,6 +4405,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("mesh_min_batch_size"), Arguments.MeshMinBatchSize); Writer->WriteValue(TEXT("profile_density"), Arguments.bProfileDensity); Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull); + Writer->WriteValue(TEXT("perf_attribution"), Arguments.bPerfAttribution); Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod); Writer->WriteValue(TEXT("op_bounds"), Arguments.bOpBounds); Writer->WriteValue(TEXT("budget_minutes"), static_cast(Arguments.BudgetMinutes)); @@ -4386,6 +4444,27 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("us_per_voxel"), SummaryUsPerVoxel); Writer->WriteValue(TEXT("triangle_count"), SummaryTriangleCount); Writer->WriteValue(TEXT("geometry_hash"), SummaryGeometryHash); + Writer->WriteObjectStart(TEXT("mesh_wall_ledger")); + Writer->WriteValue(TEXT("mesh_seconds"), World.ExploreMeshSeconds); + Writer->WriteValue(TEXT("preparation_seconds"), World.ExploreMeshPreparationSeconds); + Writer->WriteValue(TEXT("density_grid_seconds"), World.ExploreDensityGridSeconds); + Writer->WriteValue(TEXT("pre_tile_setup_seconds"), World.ExploreMeshPreTileSetupSeconds); + Writer->WriteValue(TEXT("tile_parallel_wall_seconds"), World.ExploreMeshTaskWallSeconds); + Writer->WriteValue(TEXT("tile_merge_seconds"), World.ExploreMeshMergeSeconds); + Writer->WriteValue(TEXT("accounting_remainder_seconds"), + World.ExploreMeshAccountingRemainderSeconds); + Writer->WriteValue(TEXT("stage_sum_seconds"), + World.ExploreMeshPreparationSeconds + + World.ExploreDensityGridSeconds + + World.ExploreMeshPreTileSetupSeconds + + World.ExploreMeshTaskWallSeconds + + World.ExploreMeshMergeSeconds); + Writer->WriteValue(TEXT("sum_matches_mesh"), + FMath::Abs(World.ExploreMeshAccountingRemainderSeconds) <= 0.000001); + Writer->WriteValue(TEXT("geometry_hash_seconds"), World.ExploreMeshHashSeconds); + Writer->WriteValue(TEXT("note"), TEXT( + "The stage rows are one additive wall-clock ledger. Profiler buckets below are nested worker diagnostics and are not added to this ledger.")); + Writer->WriteObjectEnd(); Writer->WriteObjectStart(TEXT("density_grid_reuse")); Writer->WriteValue(TEXT("enabled"), World.bExploreDensityGridReused); Writer->WriteValue(TEXT("total_tile_grid_samples"), World.ExploreDensityGridTotalSamples); @@ -4752,6 +4831,13 @@ FString BuildExploreJson( WriteSurfaceReliefMetrics(*Writer, Output.Export.SurfaceMetrics); Writer->WriteValue(TEXT("mesh_seconds"), Output.Export.MeshSeconds); Writer->WriteValue(TEXT("mesh_us_per_voxel"), Output.Export.MeshUsPerVoxel); + Writer->WriteValue(TEXT("geometry_hash_seconds"), World.ExploreMeshHashSeconds); + Writer->WriteValue(TEXT("measure_seconds"), Output.Export.MeasureSeconds); + Writer->WriteValue(TEXT("obj_write_seconds"), Output.Export.ObjWriteSeconds); + Writer->WriteValue(TEXT("manifest_seconds"), Output.Export.ManifestSeconds); + Writer->WriteValue(TEXT("export_wall_seconds"), Output.Export.ExportWallSeconds); + Writer->WriteValue(TEXT("export_accounting_remainder_seconds"), + Output.Export.ExportAccountingRemainderSeconds); Writer->WriteValue(TEXT("geometry_hash"), Output.Export.GeometryHash); Writer->WriteValue(TEXT("density_grid_reuse"), World.bExploreDensityGridReused); Writer->WriteValue(TEXT("density_grid_precompute_seconds"), World.ExploreDensityGridSeconds); @@ -4777,7 +4863,9 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("mode"), Output.bHasProfile && Output.Profile.Mode != VoxelDensityProfile::EMode::Disabled ? (Output.Profile.Mode == VoxelDensityProfile::EMode::Full - ? TEXT("full") : TEXT("sampled")) + ? TEXT("full") + : (Output.Profile.Mode == VoxelDensityProfile::EMode::Attribution + ? TEXT("attribution") : TEXT("sampled"))) : TEXT("disabled")); Writer->WriteValue(TEXT("sample_interval"), static_cast(Output.Profile.SampleInterval)); Writer->WriteValue(TEXT("timer_pair_cycles"), static_cast(Output.Profile.TimerPairCycles)); @@ -4788,9 +4876,12 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("generation_can_depend_on_profiler"), false); Writer->WriteValue(TEXT("fine_scope_timing"), Output.Profile.Mode == VoxelDensityProfile::EMode::Full); + Writer->WriteValue(TEXT("attribution_scope_timing"), + Output.Profile.Mode == VoxelDensityProfile::EMode::Attribution); Writer->WriteValue(TEXT("sampled_scope_policy"), TEXT( - "Sampled mode times GetDensityAt and mesh container parents; fine operation scopes are " - "opt-in via -profiledensityfull or UE Insights.")); + "Attribution mode keeps the parent and additive mesh ledger wall clocks, samples hot " + "component scopes, and records classifier detail; full mode remains the invasive per-scope " + "diagnostic.")); Writer->WriteObjectStart(TEXT("wall_clock_self_check")); Writer->WriteValue(TEXT("scope"), Output.ProfilerComparison.Scope); Writer->WriteValue(TEXT("profiled_total_seconds"), Output.ProfilerComparison.ProfiledSeconds); @@ -4803,6 +4894,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("geometry_equal"), Output.ProfilerComparison.bGeometryEqual); Writer->WriteValue(TEXT("self_check"), Output.ProfilerComparison.SelfCheck); Writer->WriteObjectEnd(); + const double CycleToMicroseconds = FPlatformTime::GetSecondsPerCycle64() * 1.0e6; Writer->WriteObjectStart(TEXT("counters")); for (int32 Index = 0; Index < VoxelDensityProfile::CounterCount; ++Index) { @@ -4811,6 +4903,42 @@ FString BuildExploreJson( static_cast(Output.Profile.Counters[Index])); } Writer->WriteObjectEnd(); + const VoxelDensityProfile::FSnapshot::FClassifierStats& Classifier = Output.Profile.Classifier; + Writer->WriteObjectStart(TEXT("classifier")); + Writer->WriteValue(TEXT("calls"), static_cast(Classifier.Calls)); + Writer->WriteValue(TEXT("sampled_calls"), static_cast(Classifier.SampledCalls)); + Writer->WriteValue(TEXT("sample_fraction"), Classifier.Calls > 0 + ? static_cast(Classifier.SampledCalls) / static_cast(Classifier.Calls) + : 0.0); + Writer->WriteValue(TEXT("refine_nodes"), static_cast(Classifier.RefineNodes)); + Writer->WriteValue(TEXT("interval_stack_box_calls"), static_cast(Classifier.StackBoxCalls)); + Writer->WriteValue(TEXT("whole_mixed_nodes"), static_cast(Classifier.WholeMixedNodes)); + Writer->WriteValue(TEXT("whole_solid_nodes"), static_cast(Classifier.WholeSolidNodes)); + Writer->WriteValue(TEXT("whole_air_nodes"), static_cast(Classifier.WholeAirNodes)); + Writer->WriteValue(TEXT("needs_final_field_nodes"), static_cast(Classifier.NeedsFinalFieldNodes)); + Writer->WriteValue(TEXT("split_nodes"), static_cast(Classifier.SplitNodes)); + Writer->WriteValue(TEXT("max_refinement_depth"), static_cast(Classifier.MaxRefinementDepth)); + Writer->WriteValue(TEXT("exact_core_samples"), static_cast(Classifier.ExactCoreSamples)); + Writer->WriteValue(TEXT("exact_final_samples"), static_cast(Classifier.ExactFinalSamples)); + Writer->WriteValue(TEXT("exact_core_cache_hits"), static_cast(Classifier.ExactCoreCacheHits)); + Writer->WriteValue(TEXT("exact_final_cache_hits"), static_cast(Classifier.ExactFinalCacheHits)); + Writer->WriteValue(TEXT("exact_core_leaves"), static_cast(Classifier.ExactCoreLeaves)); + Writer->WriteValue(TEXT("exact_final_leaves"), static_cast(Classifier.ExactFinalLeaves)); + Writer->WriteValue(TEXT("interval_stack_box_us"), static_cast(Classifier.StackBoxCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("exact_core_us"), static_cast(Classifier.ExactCoreCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("exact_final_us"), static_cast(Classifier.ExactFinalCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("room_tail_queries"), static_cast(Classifier.RoomTailQueries)); + Writer->WriteValue(TEXT("room_tail_evaluated"), static_cast(Classifier.RoomTailEvaluated)); + Writer->WriteValue(TEXT("room_tail_us"), static_cast(Classifier.RoomTailCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("room_propagate_us"), static_cast(Classifier.RoomPropagateCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("room_exact_primitive_us"), static_cast(Classifier.RoomExactPrimitiveCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("room_cache_window_us"), static_cast(Classifier.RoomCacheWindowCycles) * CycleToMicroseconds); + Writer->WriteValue(TEXT("room_count"), static_cast(Classifier.RoomNumRooms)); + Writer->WriteValue(TEXT("tunnel_count"), static_cast(Classifier.RoomNumTunnels)); + Writer->WriteValue(TEXT("room_floor_join_count"), static_cast(Classifier.RoomNumRoomFloorJoins)); + Writer->WriteValue(TEXT("pit_count"), static_cast(Classifier.RoomNumPits)); + Writer->WriteValue(TEXT("chimney_count"), static_cast(Classifier.RoomNumChimneys)); + Writer->WriteObjectEnd(); auto WriteCacheBreakdown = [&Writer]( const TCHAR* Name, const VoxelDensityProfile::FCacheMemoryBreakdown& Breakdown) @@ -4836,7 +4964,6 @@ FString BuildExploreJson( WriteCacheBreakdown(TEXT("room_graph"), Output.Profile.RoomGraphCacheBreakdown); Writer->WriteObjectEnd(); Writer->WriteObjectStart(TEXT("buckets")); - const double CycleToMicroseconds = FPlatformTime::GetSecondsPerCycle64() * 1.0e6; for (int32 Index = 0; Index < VoxelDensityProfile::BucketCount; ++Index) { Writer->WriteObjectStart(VoxelDensityProfile::BucketName( @@ -4884,7 +5011,9 @@ FString BuildExploreJson( Output.Profile.Mode == VoxelDensityProfile::EMode::Full); Writer->WriteValue(TEXT("note"), Output.Profile.Mode == VoxelDensityProfile::EMode::Full ? TEXT("Phase buckets are the non-overlapping ledger. Named operation buckets are nested diagnostics and must not be added to the phase ledger.") - : TEXT("Sampled mode intentionally disables fine phase and operation timers so the parent survives contact with reality; use -profiledensityfull or UE Insights for fine scope timing.")); + : (Output.Profile.Mode == VoxelDensityProfile::EMode::Attribution + ? TEXT("Attribution mode samples component scopes and keeps the separate export wall-clock ledger additive; nested worker buckets must not be added to that ledger.") + : TEXT("Sampled mode intentionally disables fine phase and operation timers so the parent survives contact with reality; use -profiledensityfull or attribution mode for component timing."))); Writer->WriteObjectEnd(); Writer->WriteObjectEnd(); @@ -5437,7 +5566,7 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) // serving the next case's generation. VoxelDensityProfile::Reset(); VoxelDensityProfile::SetMode( - Arguments.bProfileDensity + Arguments.IsDensityProfilingEnabled() ? ExploreProfileMode(Arguments) : VoxelDensityProfile::EMode::Disabled, VoxelDensityProfile::DefaultSampleInterval); @@ -5535,7 +5664,7 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) Mesh.Vertices.Num(), Mesh.Triangles.Num() / 3, Mesh.IsEmpty() ? 1 : 0); } - if (Arguments.bProfileDensity) + if (Arguments.IsDensityProfilingEnabled()) { VoxelDensityProfile::Reset(); VoxelDensityProfile::SetMode( @@ -5616,7 +5745,7 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) // The reference world is fresh, profiling is disabled for it, and its geometry hash is // compared with the profiled world below. This is deliberately a diagnostic second pass; // it cannot affect the profiled world's generation or its deterministic output. - if (Arguments.bProfileDensity + if (Arguments.IsDensityProfilingEnabled() && (Arguments.bRender || Arguments.bExport) && (CameraSeedWalk != nullptr || Arguments.bExport) && !Budget.bTruncated) @@ -5748,7 +5877,7 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) Output.Profile = VoxelDensityProfile::Snapshot(); Output.bHasProfile = true; - if (Arguments.bProfileDensity) + if (Arguments.IsDensityProfilingEnabled()) { if (Output.ProfilerComparison.bAvailable) { @@ -5870,7 +5999,7 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) *ReportPath, (bPayloadRepeatEqual && bFinalRepeatEqual) ? TEXT("yes") : TEXT("no")); - if (Arguments.bProfileDensity) + if (Arguments.IsDensityProfilingEnabled()) { const VoxelDensityProfile::FSnapshot Profile = VoxelDensityProfile::Snapshot(); const double CycleToMicroseconds = FPlatformTime::GetSecondsPerCycle64() * 1.0e6; @@ -6168,6 +6297,8 @@ bool BuildBatchCaseParams( bool bProfileDensityFull = false; JsonBool(Case, TEXT("profile_density_full"), bProfileDensityFull); bProfileDensity |= bProfileDensityFull; + bool bPerfAttribution = false; + JsonBool(Case, TEXT("perf_attribution"), bPerfAttribution); bool bProfileLod = false; JsonBool(Case, TEXT("profile_lod"), bProfileLod); bool bOpBounds = false; @@ -6208,6 +6339,10 @@ bool BuildBatchCaseParams( ? TEXT(" -profiledensityfull") : TEXT(" -profiledensity"); } + if (bPerfAttribution) + { + OutParams += TEXT(" -perfattribution"); + } if (bProfileLod) { OutParams += TEXT(" -profilelod");