diff --git a/docs/rtx30-compatibility.md b/docs/rtx30-compatibility.md new file mode 100644 index 00000000..938aa779 --- /dev/null +++ b/docs/rtx30-compatibility.md @@ -0,0 +1,83 @@ +# RTX 30 compatibility + +Caustica automatically selects a portable Vulkan ray-tracing path when +shader execution reordering is unavailable. + +## Default policy + +```toml +[compatibility] +as-lane-mode = "auto" +omm-mode = "auto" +``` + +Both settings are selected during Vulkan device and executor creation. +Changing either setting requires a complete game restart. + +## Expected RTX 30 behavior + +The default RTX 30 path should report: + +* Hardware profile: `nvidia-portable-rt` +* Trace backend: `portable-TraceRay` +* SER backend: `none` +* AS lane mode: `serialized` +* OMM policy: `auto` +* OMM effective state: disabled +* OMM reason: `disabled-on-nvidia-portable-profile` + +The renderer must use the packaged `_base` ray-generation shaders. + +## AS lane modes + +### `auto` + +Resolves to `serialized`. This is the supported default. + +### `serialized` + +Orders compute-side acceleration-structure work against graphics-side +terrain/TLAS use with timeline-semaphore waits. + +### `overlap` + +Experimental. Omits the cross-lane serialization wait. Do not use this +mode for release qualification. + +## OMM modes + +### `auto` + +Uses capability-driven policy. OMM is suppressed on the NVIDIA portable +profile where SER is unavailable. + +### `off` + +Always disables OMM. + +### `on` + +Requests OMM when the physical device reports support. This is an +experimental override and is not part of RTX 30 release qualification. + +## Failure behavior + +If OMM was requested but its Vulkan entry points are unavailable, +Caustica disables OMM and continues without it. + +Missing mandatory ray-tracing pipeline, acceleration-structure, or +TraceRays entry points remain a bring-up failure. + +## Reporting a compatibility result + +Include: + +* Caustica commit SHA +* Production artifact SHA-256 +* GPU model +* Operating system +* NVIDIA driver version +* Full `RT compatibility:` log line +* Full `RT bring-up OK` line +* Vulkan validation messages +* Test scenarios completed diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index bf8527ea..bfd9d269 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -32,7 +32,7 @@ public final class CausticaConfig { private static final Logger LOGGER = LoggerFactory.getLogger("Caustica"); private static final List> SETTINGS = new CopyOnWriteArrayList<>(); - static final int CONFIG_SCHEMA_VERSION = 14; + static final int CONFIG_SCHEMA_VERSION = 15; private static final Path CONFIG_PATH = resolveConfigPath(); private static final CommentedFileConfig FILE = loadAndMigrateFile(CONFIG_PATH); @@ -83,6 +83,7 @@ public static void ensureRegistered() { Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Terrain.COMPLETION_RESULTS_PER_PASS, Rt.Terrain.MAX_INFLIGHT_SECTIONS, Rt.Terrain.STREAM_BUDGET_MS, Rt.Terrain.STREAM_BUDGET_MAX_MS, Rt.Terrain.STREAM_FALLBACK_BUDGET_MS, Rt.Omm.ENABLED, + Rt.Compatibility.AS_LANE_MODE, Rt.Compatibility.OMM_MODE, Rt.Entities.ENABLED, Rt.Entities.GLOW_ENABLED, Rt.FirstPerson.ENABLED, Rt.FirstPerson.DISABLE_VANILLA_MODEL, Rt.EntityTextures.MAX_TEXTURES, Rt.Reconstruction.BACKEND, Rt.DlssRr.ENABLED, Rt.DlssRr.DIFFUSE_PATH_GUIDE, @@ -187,6 +188,12 @@ private static void writeComments() { + " grid are always active whenever RIS is on. min-fill-ratio drops emissive footprints\n" + " below that fraction of their bounding rectangle (speckle/sparse crossed planes), so\n" + " only reasonably compact glows become lights. stats/dump/dump-radius are debug logging."); + FILE.setComment("compatibility", + " Driver and hardware compatibility policy. These settings are selected during Vulkan\n" + + " device/executor creation and require a restart.\n" + + " as-lane-mode: auto, serialized, or overlap.\n" + + " omm-mode: auto, on, or off. Auto suppresses OMM on NVIDIA portable-RT\n" + + " hardware where SER is unavailable; on explicitly requests OMM when supported."); FILE.setComment("offline-renderer", " Uncapped progressive native-resolution rendering started with F7.\n" + " The scene, camera, water time, and exposure are frozen for the session.\n" @@ -373,15 +380,23 @@ static boolean migrateLegacySceneConfig(CommentedConfig config) { if (version < 14) { applySchema14Defaults(config); } + if (version == 14) { + applySchema15CompatibilityDefaults(config); + } config.set("config-version", CONFIG_SCHEMA_VERSION); return true; } private static void applySchema14Defaults(CommentedConfig config) { - migrateExactNumber(config, "composite.max-bounces", 8.0, 64.0); config.remove("composite.celestial-light-bounces"); } + private static void applySchema15CompatibilityDefaults(CommentedConfig config) { + // Only schema-14 files could have received the faulty generated defaults in the prior release. + migrateExactNumber(config, "composite.max-bounces", 64.0, 8.0); + migrateExactNumber(config, "lights.ris-candidates", 8.0, 0.0); + } + private static boolean isNrdBackend(CommentedConfig config) { Object rawBackend = config.get("reconstruction.backend"); return rawBackend instanceof String value && "nrd".equalsIgnoreCase(value.trim()); @@ -1041,6 +1056,34 @@ private Omm() { } } + public static final class Compatibility { + public static final StringSetting AS_LANE_MODE = string( + "caustica.rt.asLaneMode", "compatibility.as-lane-mode", "auto", + Compatibility::sanitizeAsLaneMode); + public static final StringSetting OMM_MODE = string( + "caustica.rt.ommMode", "compatibility.omm-mode", "auto", + Compatibility::sanitizeOmmMode); + + private Compatibility() { + } + + private static String sanitizeAsLaneMode(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "serialized", "overlap" -> normalized; + default -> "auto"; + }; + } + + private static String sanitizeOmmMode(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "on", "off" -> normalized; + default -> "auto"; + }; + } + } + public static final class Sharc { public static final BooleanSetting ENABLED = bool("caustica.rt.sharc", "sharc.enabled", true); public static final IntSetting CACHE_EXPONENT = diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaDebugBridge.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaDebugBridge.java index 9bfab223..d56ae6e0 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaDebugBridge.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaDebugBridge.java @@ -64,13 +64,12 @@ private static void applyCommand(Minecraft client) { setBoolean(command, "frameStats", CausticaConfig.Rt.FrameStats.ENABLED); boolean terrainSettingsChanged = command.containsKey("terrainDispatch") || command.containsKey("terrainResults") || command.containsKey("terrainInflight") - || command.containsKey("terrainBuildBatch") || command.containsKey("omm") + || command.containsKey("terrainBuildBatch") || command.containsKey("ommSubdivision"); setInt(command, "terrainDispatch", CausticaConfig.Rt.Terrain.ASYNC_DISPATCH_PER_PASS); setInt(command, "terrainResults", CausticaConfig.Rt.Terrain.COMPLETION_RESULTS_PER_PASS); setInt(command, "terrainInflight", CausticaConfig.Rt.Terrain.MAX_INFLIGHT_SECTIONS); setInt(command, "terrainBuildBatch", CausticaConfig.Rt.Terrain.GPU_BUILD_BATCH_SIZE); - setBoolean(command, "omm", CausticaConfig.Rt.Omm.ENABLED); setInt(command, "ommSubdivision", CausticaConfig.Rt.Omm.SUBDIVISION); if (terrainSettingsChanged) RtTerrain.requestFullClear(); String terrainBenchmark = command.getProperty("terrainBenchmark"); @@ -269,6 +268,16 @@ private static void publishState(Minecraft client) { state.setProperty("outputScaleFailure", RtComposite.INSTANCE.outputScaleFailure()); state.setProperty("backend", RtRuntimeStatus.backend()); state.setProperty("rtRequested", Boolean.toString(RtDeviceBringup.rtRequested())); + state.setProperty("hardwareProfile", RtDeviceBringup.hardwareProfile()); + state.setProperty("portableTraceBackend", Boolean.toString(RtDeviceBringup.portableTraceBackend())); + state.setProperty("serBackend", RtDeviceBringup.serBackendLabel()); + state.setProperty("ommSupported", Boolean.toString(RtDeviceBringup.opacityMicromapSupported())); + state.setProperty("ommPolicy", RtDeviceBringup.ommPolicy()); + state.setProperty("ommEffective", Boolean.toString(RtDeviceBringup.ommEnabled())); + state.setProperty("ommReason", RtDeviceBringup.ommEffectiveReason()); + state.setProperty("driverRaw", Integer.toHexString(RtDeviceBringup.rawDriverVersion())); + state.setProperty("asLaneMode", RtRuntimeStatus.actualAsLaneMode()); + state.setProperty("asLaneModeConfigured", CausticaConfig.Rt.Compatibility.AS_LANE_MODE.get()); state.setProperty("rtContextReady", Boolean.toString(RtRuntimeStatus.rtContextReady())); state.setProperty("rtFailureLatched", Boolean.toString(RtComposite.INSTANCE.hasFailed())); state.setProperty("rtStatus", RtRuntimeStatus.unavailableReason()); @@ -284,6 +293,9 @@ private static void publishState(Minecraft client) { state.setProperty("terrainGpuQueueDepth", Integer.toString(terrain.gpuQueueDepth())); state.setProperty("terrainBuildsSubmitted", Long.toString(terrain.buildsSubmitted())); state.setProperty("terrainBuildsPublished", Long.toString(terrain.buildsPublished())); + state.setProperty("terrainInteractiveOutstanding", Long.toString(RtTerrain.interactiveOutstanding())); + state.setProperty("terrainInteractiveSubmitted", Long.toString(RtTerrain.interactiveSubmittedTotal())); + state.setProperty("terrainInteractivePublished", Long.toString(RtTerrain.interactivePublishedTotal())); state.setProperty("terrainBuildLatencyNanos", Long.toString(terrain.lastBuildLatencyNanos())); state.setProperty("terrainBuildLatencyKind", "host-submit-to-timeline-completion"); state.setProperty("terrainActiveCompactionQueries", Integer.toString(terrain.activeCompactionQueries())); @@ -294,7 +306,8 @@ private static void publishState(Minecraft client) { state.setProperty("terrainResults", Integer.toString(CausticaConfig.Rt.Terrain.COMPLETION_RESULTS_PER_PASS.value())); state.setProperty("terrainInflight", Integer.toString(CausticaConfig.Rt.Terrain.MAX_INFLIGHT_SECTIONS.value())); state.setProperty("terrainBuildBatch", Integer.toString(CausticaConfig.Rt.Terrain.GPU_BUILD_BATCH_SIZE.value())); - state.setProperty("terrainOmm", Boolean.toString(CausticaConfig.Rt.Omm.ENABLED.value())); + state.setProperty("terrainOmm", Boolean.toString(RtDeviceBringup.ommEnabled())); + state.setProperty("terrainOmmConfigured", Boolean.toString(CausticaConfig.Rt.Omm.ENABLED.value())); state.setProperty("terrainOmmSubdivision", Integer.toString(CausticaConfig.Rt.Omm.SUBDIVISION.value())); state.setProperty("terrainBenchmarkStartedNanos", Long.toString(streaming.startedNanos())); state.setProperty("terrainDesired", Integer.toString(streaming.desired())); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index e7fc0697..cf5699d1 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -70,6 +70,11 @@ * device features to an already-created device, so a config change only takes effect on restart. */ public final class RtDeviceBringup { + public enum AccelerationStructureLaneMode { + SERIALIZED, + OVERLAP + } + public static boolean enabledByProperty() { return CausticaConfig.Rt.ENABLED.value(); } @@ -98,10 +103,9 @@ public static boolean enabledByProperty() { /** * OPTIONAL RT extensions: enabled only when the selected device supports them AND the gate is on, but * never required — a device lacking them still comes up RT-capable (unlike {@link #RT_EXTENSIONS}, whose - * absence disables RT entirely). {@code VK_EXT_opacity_micromap} (any-hit opt, lever C): per-triangle - * opacity micromaps let the hardware skip {@code world.rahit} on fully-opaque/transparent cutout micro- - * triangles, so the alpha-test any-hit runs only on the foliage silhouette. Hardware-accelerated on RTX - * 40-series and Blackwell; absent / software elsewhere, hence optional. + * absence disables RT entirely). {@code VK_EXT_opacity_micromap} lets the hardware skip {@code world.rahit} + * on fully-opaque/transparent cutout micro-triangles. OMM is optional across RTX hardware; portable + * NVIDIA profiles use the ordinary any-hit path by default unless explicitly forced. */ public static final List OPTIONAL_RT_EXTENSIONS = List.of( VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); @@ -109,6 +113,10 @@ public static boolean enabledByProperty() { private static volatile boolean rtRequested; private static volatile SerBackend serBackend = SerBackend.NONE; private static volatile boolean ommEnabled; // VK_EXT_opacity_micromap actually enabled on the device + private static volatile boolean opacityMicromapSupported; + private static volatile boolean ommEntryPointsAvailable; + private static volatile boolean nvidiaDevice; + private static volatile int rawDriverVersion; private static volatile boolean traceRaysIndirectSupported; private static volatile int maxRayDispatchInvocationCount; private static volatile boolean wideLinesEnabled; // VkPhysicalDeviceFeatures.wideLines actually enabled @@ -178,7 +186,7 @@ public static boolean enabledByProperty() { DESCRIPTOR_PARTIALLY_BOUND_FEATURE, SAMPLED_IMAGE_UPDATE_AFTER_BIND_FEATURE, SHADER_INT64_FEATURE, ACCELERATION_STRUCTURE_FEATURE, RAY_TRACING_PIPELINE_FEATURE, POSITION_FETCH_FEATURE, RAY_QUERY_FEATURE); - private enum SerBackend { + enum SerBackend { NONE("none", null, "world_base.rgen.spv"), NV("NV", VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world_nv.rgen.spv"), EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world.rgen.spv"); @@ -265,10 +273,10 @@ public static String sharcPrimaryDiagnosticQueryRaygenShader() { "world_sharc_primary_diagnostic_base.rgen.spv"); } - private record FeatureSupport(List missingRequired, SerBackend serBackend, - boolean omm, boolean wideLines) { + record FeatureSupport(List missingRequired, SerBackend serBackend, + boolean ommSupported, boolean wideLines) { boolean supportsRt() { - return missingRequired.isEmpty() && serBackend != SerBackend.NONE; + return missingRequired.isEmpty(); } } @@ -323,6 +331,65 @@ public static boolean ommEnabled() { return ommEnabled; } + /** True if the physical device exposed the OMM feature during bring-up. */ + public static boolean opacityMicromapSupported() { + return opacityMicromapSupported; + } + + public static String serBackendLabel() { + return serBackend.label; + } + + public static boolean portableTraceBackend() { + return serBackend == SerBackend.NONE; + } + + public static String hardwareProfile() { + if (nvidiaDevice) { + return portableTraceBackend() ? "nvidia-portable-rt" : "nvidia-modern-rt"; + } + return "cross-vendor-rt"; + } + + public static int rawDriverVersion() { + return rawDriverVersion; + } + + public static String ommPolicy() { + return CausticaConfig.Rt.Compatibility.OMM_MODE.get(); + } + + public static String ommEffectiveReason() { + if (!CausticaConfig.Rt.Omm.ENABLED.value()) { + return "disabled-by-user-gate"; + } + if (!opacityMicromapSupported) { + return "unsupported"; + } + if ("off".equals(ommPolicy())) { + return "disabled-by-compatibility-policy"; + } + if (!ommEntryPointsAvailable) { + return "omm-entry-points-unavailable"; + } + if (nvidiaDevice && serBackend == SerBackend.NONE && "auto".equals(ommPolicy())) { + return "disabled-on-nvidia-portable-profile"; + } + return ommEnabled ? "enabled" : "not-requested"; + } + + public static AccelerationStructureLaneMode requestedAsLaneMode() { + return requestedAsLaneMode(CausticaConfig.Rt.Compatibility.AS_LANE_MODE.get()); + } + + static AccelerationStructureLaneMode requestedAsLaneMode(String configured) { + return switch (configured == null ? "auto" : configured.trim().toLowerCase(java.util.Locale.ROOT)) { + case "overlap" -> AccelerationStructureLaneMode.OVERLAP; + case "serialized" -> AccelerationStructureLaneMode.SERIALIZED; + default -> AccelerationStructureLaneMode.SERIALIZED; + }; + } + public static boolean traceRaysIndirectSupported() { return traceRaysIndirectSupported; } @@ -358,17 +425,29 @@ public static int overlayMsaaSamples() { } /** Optional extensions the gate wants AND the device supports — added but never required. */ - private static List supportedOptionalExtensions(FeatureSupport support) { + private static List supportedOptionalExtensions(FeatureSupport support, boolean nvidia) { List supported = new ArrayList<>(); - if (support.omm) supported.add(VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); + if (support.ommSupported && effectiveOmmRequested(nvidia, support.serBackend)) { + supported.add(VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); + } return supported; } - private static boolean ommRequested() { - // OMM is an optional acceleration representation with a complete any-hit fallback. Keep the - // persisted device-creation gate authoritative so a faulting driver/workload can disable the - // extension before Vulkan device creation rather than merely skipping its later classifier. - return CausticaConfig.Rt.Omm.ENABLED.value(); + static boolean effectiveOmmRequested(boolean nvidia, SerBackend candidateBackend) { + return effectiveOmmRequested(nvidia, candidateBackend, + CausticaConfig.Rt.Omm.ENABLED.value(), CausticaConfig.Rt.Compatibility.OMM_MODE.get()); + } + + static boolean effectiveOmmRequested(boolean nvidia, SerBackend candidateBackend, + boolean userEnabled, String policy) { + if (!userEnabled) { + return false; + } + return switch (policy == null ? "auto" : policy.trim().toLowerCase(java.util.Locale.ROOT)) { + case "on" -> true; + case "off" -> false; + default -> !(nvidia && candidateBackend == SerBackend.NONE); + }; } /** Optional SHaRC feature query; absence keeps the separately packaged SHaRC shaders disabled. */ @@ -394,8 +473,7 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); if (hasSerExt) SER_EXT_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); if (hasSerNv) SER_NV_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); - boolean queryOmm = ommRequested() - && physicalDevice.hasDeviceExtension(VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); + boolean queryOmm = physicalDevice.hasDeviceExtension(VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); if (queryOmm) OMM_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); WIDE_LINES_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); VK12.vkGetPhysicalDeviceFeatures2(physicalDevice.vkPhysicalDevice(), available); @@ -406,7 +484,6 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD } SerBackend supportedSer = hasSerExt && SER_EXT_FEATURE.get(available) ? SerBackend.EXT : hasSerNv && SER_NV_FEATURE.get(available) ? SerBackend.NV : SerBackend.NONE; - if (supportedSer == SerBackend.NONE) missing.add("rayTracingInvocationReorder(NV or EXT)"); return new FeatureSupport(missing, supportedSer, queryOmm && OMM_FEATURE.get(available), WIDE_LINES_FEATURE.get(available)); } @@ -437,7 +514,7 @@ public static void addExtensions(List augmentedExtensions, VulkanPhysica if (serExtension != null && !augmentedExtensions.contains(serExtension)) { augmentedExtensions.add(serExtension); } - for (String ext : supportedOptionalExtensions(support)) { + for (String ext : supportedOptionalExtensions(support, isNvidia(physicalDevice))) { if (!augmentedExtensions.contains(ext)) { augmentedExtensions.add(ext); } @@ -453,6 +530,10 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { rtRequested = false; serBackend = SerBackend.NONE; ommEnabled = false; + opacityMicromapSupported = false; + ommEntryPointsAvailable = false; + nvidiaDevice = isNvidia(physicalDevice); + rawDriverVersion = physicalDevice.vkPhysicalDeviceProperties().driverVersion(); wideLinesEnabled = false; sharcInt64AtomicsEnabled = false; maxLineWidth = 1.0f; @@ -514,7 +595,10 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { // Optional: opacity micromaps (any-hit opt). Only when the gate is on AND the device advertises the // extension — its absence must not disable RT, so it is kept out of the mandatory feature set above. - ommEnabled = support.omm; + opacityMicromapSupported = support.ommSupported; + ommEnabled = opacityMicromapSupported + && effectiveOmmRequested(nvidiaDevice, selectedSerBackend); + ommEntryPointsAvailable = true; if (ommEnabled) { features.add(OMM_FEATURE); } @@ -523,6 +607,13 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { rtRequested = true; serBackend = selectedSerBackend; + CausticaMod.LOGGER.info( + "RT compatibility: device='{}' profile={} traceBackend={} ser={} ommSupported={} " + + "ommConfigured={} ommPolicy={} ommEnabled={} ommReason={} driverRaw=0x{}", + physicalDevice.deviceName(), hardwareProfile(), + portableTraceBackend() ? "portable-TraceRay" : "HitObject", serBackend.label, + opacityMicromapSupported, CausticaConfig.Rt.Omm.ENABLED.value(), ommPolicy(), ommEnabled, + ommEffectiveReason(), Integer.toHexString(rawDriverVersion)); CausticaMod.LOGGER.info( "Ray tracing: enabling {}{}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, optionalInvocationReorder({}), liveReorder=off, offlineReorder={}" + (wideLinesEnabled ? ", wideLines(max=" + maxLineWidth + ")" : "") @@ -533,6 +624,10 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { serBackend != SerBackend.NONE ? "on" : "off", physicalDevice.deviceName()); } + private static boolean isNvidia(VulkanPhysicalDevice physicalDevice) { + return physicalDevice.vkPhysicalDeviceProperties().vendorID() == 0x10DE; + } + /** * Post-creation verification: confirm the RT entry points actually loaded on the new * device and log the RT pipeline / acceleration-structure limits. If this logs "OK", @@ -552,12 +647,22 @@ public static void probe(VkDevice device) { boolean asBuild = caps.vkCmdBuildAccelerationStructuresKHR != 0L; boolean traceRays = caps.vkCmdTraceRaysKHR != 0L; traceRaysIndirectSupported = caps.vkCmdTraceRaysIndirectKHR != 0L; + ommEntryPointsAvailable = !ommEnabled || ( + caps.vkGetMicromapBuildSizesEXT != 0L + && caps.vkCreateMicromapEXT != 0L + && caps.vkCmdBuildMicromapsEXT != 0L); if (!(rtPipeline && asBuild && traceRays)) { CausticaMod.LOGGER.error( "RT extensions enabled but entry points missing (rtPipeline={}, asBuild={}, traceRays={}) — RT bring-up FAILED", rtPipeline, asBuild, traceRays); return; } + if (!ommEntryPointsAvailable) { + ommEnabled = false; + maxOpacity4StateSubdivisionLevel = 0; + CausticaMod.LOGGER.warn( + "OMM was enabled during device creation but required entry points are missing; disabling OMM"); + } try (MemoryStack stack = MemoryStack.stackPush()) { VkPhysicalDeviceAccelerationStructurePropertiesKHR asProps = VkPhysicalDeviceAccelerationStructurePropertiesKHR.calloc(stack).sType$Default(); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 21ce6d58..41a065f0 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -85,9 +85,10 @@ public final class RtFrameStats { new String[] {"sectionsSnapshotted", "sectionsUploaded", "sectionCopies", "terrainBuildsCompleted", "terrainBuildsSubmitted", "terrainBuildsPublished", "terrainCancelledTasks", "terrainDiscardedBuilds", "terrainMaterialEpochRejects", "terrainOutstandingTasks", - "terrainOutstandingLimit", "terrainQueuedMissing", "terrainQueuedReextract", - "terrainResidentSections", "terrainGpuQueueDepth", "terrainBuildLatencyNanos", - "terrainActiveCompactionQueries", + "terrainOutstandingLimit", "terrainQueuedMissing", "terrainQueuedReextract", + "terrainResidentSections", "terrainGpuQueueDepth", "terrainBuildLatencyNanos", + "terrainInteractiveOutstanding", "terrainInteractiveSubmitted", "terrainInteractivePublished", + "terrainActiveCompactionQueries", "entitiesCaptured", "blockEntitiesCaptured", "particlesCaptured", "refits", "entityReuse", "entityRigidFitSuccesses", "entityRigidFitFailures", "vmaBufferCreates", diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtGpuExecutor.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtGpuExecutor.java index 419bd168..64d54326 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtGpuExecutor.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtGpuExecutor.java @@ -2,6 +2,7 @@ import com.mojang.blaze3d.vulkan.VulkanCommandEncoder; import com.mojang.blaze3d.vulkan.VulkanQueue; +import dev.comfyfluffy.caustica.CausticaMod; import org.lwjgl.PointerBuffer; import org.lwjgl.system.MemoryStack; import org.lwjgl.vulkan.VK10; @@ -21,8 +22,9 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.concurrent.CancellationException; -import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @@ -43,8 +45,11 @@ public final class RtGpuExecutor { private static int maxBuildBatch() { return dev.comfyfluffy.caustica.CausticaConfig.Rt.Terrain.GPU_BUILD_BATCH_SIZE.value(); } - private static final Job STOP = new Job(null, null, null, null, null); - private static final Job WAKE = new Job(null, null, null, null, null); + private static final int KIND_BUILD = 0; + private static final int KIND_WAKE = 1; + private static final int KIND_STOP = 2; + private static final Job WAKE = new Job(null, null, null, null, null, KIND_WAKE, false, Long.MAX_VALUE - 1L); + private static final Job STOP = new Job(null, null, null, null, null, KIND_STOP, false, Long.MAX_VALUE); private static final long TERRAIN_READ_STAGES = VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR; @@ -53,8 +58,10 @@ private static int maxBuildBatch() { private final VulkanQueue computeQueue; private final long buildTimeline; private final long graphicsTimeline; - private final LinkedBlockingQueue jobs = new LinkedBlockingQueue<>(); - private final AtomicLong nextBuildValue = new AtomicLong(); + private final RtDeviceBringup.AccelerationStructureLaneMode asLaneMode; + private final PriorityBlockingQueue jobs = new PriorityBlockingQueue<>(); + private final AtomicLong nextJobSequence = new AtomicLong(); + private final AtomicLong nextTimelineValue = new AtomicLong(); private final AtomicLong pendingPublishWaitValue = new AtomicLong(); private final AtomicLong latestSubmittedBuildValue = new AtomicLong(); private final AtomicLong nextGraphicsValue = new AtomicLong(); @@ -71,6 +78,9 @@ private static int maxBuildBatch() { RtGpuExecutor(RtContext ctx) { this.ctx = ctx; this.computeQueue = ctx.computeQueue(); + this.asLaneMode = RtDeviceBringup.requestedAsLaneMode(); + CausticaMod.LOGGER.info("RT acceleration-structure lane mode: {}", + asLaneMode.name().toLowerCase(Locale.ROOT)); this.buildTimeline = createTimeline("RT terrain build timeline"); this.graphicsTimeline = createTimeline("RT graphics-use timeline"); createCommandPool(); @@ -85,7 +95,7 @@ private static int maxBuildBatch() { */ public Build submit(Consumer record, Runnable afterSuccess, BiConsumer finished) { - return submit(() -> false, record, afterSuccess, finished); + return submit(false, () -> false, record, afterSuccess, finished); } /** @@ -94,14 +104,21 @@ public Build submit(Consumer record, Runnable afterSuccess, */ public synchronized Build submit(BooleanSupplier cancelled, Consumer record, Runnable afterSuccess, BiConsumer finished) { + return submit(false, cancelled, record, afterSuccess, finished); + } + + /** Enqueue a terrain build with bounded interactive priority. */ + public synchronized Build submit(boolean interactive, BooleanSupplier cancelled, + Consumer record, Runnable afterSuccess, + BiConsumer finished) { checkExecutorFailure(); if (closed) { throw new IllegalStateException("RT GPU executor is closed"); } - long value = nextBuildValue.incrementAndGet(); - Build build = new Build(value); + Build build = new Build(); queuedBuilds.incrementAndGet(); - jobs.add(new Job(cancelled, record, afterSuccess, finished, build)); + jobs.add(new Job(cancelled, record, afterSuccess, finished, build, KIND_BUILD, + interactive, nextJobSequence.incrementAndGet())); return build; } @@ -119,7 +136,9 @@ public void markPublished(Build build) { public long beginGraphicsTerrainUse(VulkanCommandEncoder encoder) { checkExecutorFailure(); synchronized (asLaneOrderLock) { - long waitValue = Math.max(pendingPublishWaitValue.get(), latestSubmittedBuildValue.get()); + long waitValue = serializesAccelerationStructureLanes() + ? Math.max(pendingPublishWaitValue.get(), latestSubmittedBuildValue.get()) + : pendingPublishWaitValue.get(); if (waitValue != 0L) { encoder.waitSemaphore(buildTimeline, waitValue, TERRAIN_READ_STAGES); } @@ -157,6 +176,14 @@ public long latestGraphicsUseValue() { return latestGraphicsUseValue.get(); } + public boolean serializesAccelerationStructureLanes() { + return asLaneMode == RtDeviceBringup.AccelerationStructureLaneMode.SERIALIZED; + } + + public String accelerationStructureLaneMode() { + return asLaneMode.name().toLowerCase(Locale.ROOT); + } + /** Rethrow a latched executor failure on the calling thread. */ public void throwIfFailed() { checkExecutorFailure(); @@ -386,7 +413,12 @@ private void execute(List batch) { ArrayList commands = new ArrayList<>(batch.size()); boolean submitted = false; boolean completed = false; - long signalValue = batch.get(batch.size() - 1).build.value; + long signalValue = assignTimelineValues(batch); + for (int i = 1; i < batch.size(); i++) { + if (batch.get(i - 1).build.value() >= batch.get(i).build.value()) { + throw new IllegalStateException("GPU build timeline values are not increasing"); + } + } long firstValue = batch.get(0).build.value; VulkanDiagnostics.setInFlight("async-compute", "recording builds=" + firstValue + ".." + signalValue + " batch=" + batch.size() @@ -418,10 +450,9 @@ private void execute(List batch) { synchronized (asLaneOrderLock) { long priorGraphicsUse = latestGraphicsUseValue.get(); VkSemaphoreSubmitInfo.Buffer wait = null; - if (priorGraphicsUse != 0L) { - // NVIDIA 610.62 has repeatedly faulted when terrain AS builds overlap a graphics TLAS - // build/trace, even though the structures and scratch allocations are disjoint. Alternate - // the two AS lanes through timelines, with this reservation ordered against graphics. + if (serializesAccelerationStructureLanes() && priorGraphicsUse != 0L) { + // Serialized mode keeps terrain AS work ordered against graphics TLAS use. Explicit + // overlap mode is experimental and intentionally omits this cross-lane wait. wait = VkSemaphoreSubmitInfo.calloc(1, stack).sType$Default() .semaphore(graphicsTimeline).value(priorGraphicsUse) .stageMask(VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR); @@ -458,6 +489,24 @@ private void execute(List batch) { } } + private long assignTimelineValues(List executable) { + ArrayList builds = new ArrayList<>(executable.size()); + for (Job job : executable) { + builds.add(job.build); + } + return assignTimelineValues(builds, nextTimelineValue); + } + + static long assignTimelineValues(List executionOrder, AtomicLong nextValue) { + long maximum = 0L; + for (Build build : executionOrder) { + long value = nextValue.incrementAndGet(); + build.assignTimelineValue(value); + maximum = Math.max(maximum, value); + } + return maximum; + } + private long createTimeline(String label) { try (MemoryStack stack = MemoryStack.stackPush()) { VkSemaphoreTypeCreateInfo type = VkSemaphoreTypeCreateInfo.calloc(stack).sType$Default() @@ -502,16 +551,33 @@ private void waitTimeline(long semaphore, long value) { } public static final class Build { - private final long value; + private volatile long value; private final long enqueuedNanos; - private Build(long value) { - this.value = value; + Build() { this.enqueuedNanos = System.nanoTime(); } public long value() { - return value; + long current = value; + if (current == 0L) { + throw new IllegalStateException("build has not entered a GPU submission"); + } + return current; + } + + public boolean submitted() { + return value != 0L; + } + + void assignTimelineValue(long value) { + if (value <= 0L) { + throw new IllegalArgumentException("timeline value must be positive"); + } + if (this.value != 0L) { + throw new IllegalStateException("timeline value already assigned"); + } + this.value = value; } /** Host-observed time since this build was accepted; this is not a GPU timestamp. */ @@ -521,7 +587,20 @@ public long ageNanos() { } private record Job(BooleanSupplier cancelled, Consumer record, Runnable afterSuccess, - BiConsumer finished, Build build) { + BiConsumer finished, Build build, int kind, + boolean interactive, long sequence) implements Comparable { + @Override + public int compareTo(Job other) { + int kindOrder = Integer.compare(kind, other.kind); + if (kindOrder != 0) { + return kindOrder; + } + if (kind != KIND_BUILD) { + return Long.compare(sequence, other.sequence); + } + return TerrainJobOrder.compare(interactive, sequence, + other.interactive, other.sequence); + } } private record DestroyJob(long lastUseValue, Runnable destroy) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtRuntimeStatus.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtRuntimeStatus.java index 0fcf1185..35d35a53 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtRuntimeStatus.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtRuntimeStatus.java @@ -34,6 +34,11 @@ public static boolean rtContextReady() { return RtContext.currentOrNull() != null; } + public static String actualAsLaneMode() { + RtContext context = RtContext.currentOrNull(); + return context == null ? "not-initialized" : context.gpuExecutor().accelerationStructureLaneMode(); + } + public static String unavailableReason() { if (!vulkan()) return "Caustica requires Vulkan; current backend is " + backend(); if (!RtDeviceBringup.rtRequested()) return "Vulkan device did not request Caustica RT features"; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/TerrainJobOrder.java b/src/main/java/dev/comfyfluffy/caustica/rt/TerrainJobOrder.java new file mode 100644 index 00000000..fc48896d --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/TerrainJobOrder.java @@ -0,0 +1,23 @@ +package dev.comfyfluffy.caustica.rt; + +/** Bounded priority ordering for interactive terrain work without starving older streaming work. */ +public final class TerrainJobOrder { + static final long INTERACTIVE_SEQUENCE_BOOST = 64L; + + private TerrainJobOrder() { + } + + public static long orderKey(boolean interactive, long sequence) { + if (sequence <= 0L) { + throw new IllegalArgumentException("sequence must be positive"); + } + return interactive ? Math.max(0L, sequence - INTERACTIVE_SEQUENCE_BOOST) : sequence; + } + + public static int compare(boolean leftInteractive, long leftSequence, + boolean rightInteractive, long rightSequence) { + int keyOrder = Long.compare(orderKey(leftInteractive, leftSequence), + orderKey(rightInteractive, rightSequence)); + return keyOrder != 0 ? keyOrder : Long.compare(leftSequence, rightSequence); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java index 5b2da108..782d91b4 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java @@ -58,6 +58,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.LongAdder; import java.util.concurrent.atomic.AtomicLong; @@ -130,6 +131,7 @@ private static int maxInflight() { // screens / hidden window — states where render-driven streaming has stopped). private static final long STREAM_FALLBACK_AFTER_NANOS = 200_000_000L; private static final long LIGHT_HIERARCHY_UPDATE_INTERVAL_NANOS = 100_000_000L; + static final int INTERACTIVE_BURST_SLOTS = 4; private static int sectionTableInitialCapacity() { return CausticaConfig.Rt.Terrain.SECTION_TABLE_INITIAL_CAPACITY.value(); @@ -159,6 +161,7 @@ private static int rebaseDistanceBlocks() { private final LongOpenHashSet loadedColumns = new LongOpenHashSet(); private final LongArrayList missing = new LongArrayList(); private final Long2IntOpenHashMap missingIndex = new Long2IntOpenHashMap(); + private final TerrainMissingPriority interactiveMissing = new TerrainMissingPriority(); private final Long2LongOpenHashMap queuedDirtyGroup = new Long2LongOpenHashMap(); private final LongArrayList reextract = new LongArrayList(); private final LongOpenHashSet queuedReextract = new LongOpenHashSet(); @@ -166,7 +169,7 @@ private static int rebaseDistanceBlocks() { // frames, so evicted geometry waits here until the next publish pass retires it. private final List removed = new ArrayList<>(); private final List prepared = new ArrayList<>(); - private final IdentityHashMap publicationTickets = + private final IdentityHashMap publicationTickets = new IdentityHashMap<>(); // Publish identity and admission ownership are separate: cancellation revokes a ticket immediately, // while TerrainTaskTracker retains its capacity until the terminal result is drained. @@ -174,6 +177,8 @@ private static int rebaseDistanceBlocks() { private final Long2LongOpenHashMap inFlightDirtyGroup = new Long2LongOpenHashMap(); private final Long2ObjectOpenHashMap dirtyGroups = new Long2ObjectOpenHashMap<>(); private final ConcurrentLinkedQueue completedBuilds = new ConcurrentLinkedQueue<>(); + private final ConcurrentLinkedQueue interactiveCompletedBuilds = new ConcurrentLinkedQueue<>(); + private final TerrainCompletionFairness completionFairness = new TerrainCompletionFairness<>(); private final Object activeTaskLock = new Object(); private int activeTasks; /** Invalidates all worker/GPU work from a detached world residency without joining it. */ @@ -185,6 +190,12 @@ private static int rebaseDistanceBlocks() { private final AtomicLong buildsSubmitted = new AtomicLong(); private final AtomicLong buildsSubmittedSinceSample = new AtomicLong(); private final AtomicLong buildsPublished = new AtomicLong(); + private final AtomicLong buildsPublishedSinceSample = new AtomicLong(); + private final AtomicLong interactiveOutstanding = new AtomicLong(); + private final AtomicLong interactiveSubmittedTotal = new AtomicLong(); + private final AtomicLong interactivePublishedTotal = new AtomicLong(); + private final AtomicLong interactiveSubmittedSinceSample = new AtomicLong(); + private final AtomicLong interactivePublishedSinceSample = new AtomicLong(); private volatile long lastBuildLatencyNanos; private final RtSectionTable table = new RtSectionTable(); private boolean ready; @@ -345,7 +356,19 @@ public static Status status() { public record Status(int outstandingTasks, int outstandingLimit, int queuedMissing, int queuedReextract, int residentSections, long cancelledTasks, long discardedBuilds, int gpuQueueDepth, long buildsSubmitted, long buildsPublished, long lastBuildLatencyNanos, - int activeCompactionQueries) { + int activeCompactionQueries) { + } + + public static long interactiveOutstanding() { + return INSTANCE.interactiveOutstanding.get(); + } + + public static long interactiveSubmittedTotal() { + return INSTANCE.interactiveSubmittedTotal.get(); + } + + public static long interactivePublishedTotal() { + return INSTANCE.interactivePublishedTotal.get(); } /** Per-tick residency update: window sync + dirty drain (plus the streaming fallback, see {@link #frame}). */ @@ -468,7 +491,7 @@ public static StreamingStats streamingStats() { active = t.activeTasks; } int workers = benchmarkWorkerActive.get(); - int completed = t.completedBuilds.size(); + int completed = t.completedBuilds.size() + t.interactiveCompletedBuilds.size(); int inFlight = t.taskTracker.outstanding(); RtContext ctx = RtContext.currentOrNull(); int gpuQueued = ctx == null ? 0 : ctx.gpuExecutor().queuedBuilds(); @@ -579,7 +602,7 @@ private void stream(RtContext ctx, boolean fallback) { return; } if (reextract.isEmpty() && missing.isEmpty() - && completedBuilds.isEmpty() + && completedBuilds.isEmpty() && interactiveCompletedBuilds.isEmpty() && !lightGrid.hasCompletions() && !lightHierarchyDirty && removed.isEmpty() && prepared.isEmpty()) { @@ -592,7 +615,8 @@ private void stream(RtContext ctx, boolean fallback) { ClientChunkCache chunkSource = level.getChunkSource(); long started = System.nanoTime(); - int queued = missing.size() + reextract.size() + completedBuilds.size(); + int queued = missing.size() + reextract.size() + + completedBuilds.size() + interactiveCompletedBuilds.size(); long budget = fallback ? TerrainStreamBudget.fixedBudgetNanos(CausticaConfig.Rt.Terrain.STREAM_FALLBACK_BUDGET_MS.value()) : TerrainStreamBudget.adaptiveBudgetNanos( @@ -625,19 +649,33 @@ private void stream(RtContext ctx, boolean fallback) { // Snapshot and dispatch a bounded number of new worker-owned section builds. try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("terrain.snapshotDispatch")) { DispatchContext dispatch = null; - int dispatchSlots = Math.min(asyncDispatchPerPass(), - Math.max(0, maxInflight() - taskTracker.outstanding())); - if (dispatchSlots > 0 && !reextract.isEmpty() && System.nanoTime() < deadline) { + int dispatchSlots = asyncDispatchPerPass(); + int interactiveSlots = Math.min(dispatchSlots, + Math.max(0, maxInflight() + INTERACTIVE_BURST_SLOTS - taskTracker.outstanding())); + if (interactiveSlots > 0 && !reextract.isEmpty() && System.nanoTime() < deadline) { if (dispatch == null) { dispatch = dispatchContext(ctx, level); } - dispatchSlots -= dispatchReextract(dispatch, chunkSource, dispatchSlots, pcx, psy, pcz, deadline); + int dispatched = dispatchReextract(dispatch, chunkSource, interactiveSlots, pcx, psy, pcz, deadline); + interactiveSlots -= dispatched; + dispatchSlots -= dispatched; } - if (dispatchSlots > 0 && !missing.isEmpty() && System.nanoTime() < deadline) { + if (interactiveSlots > 0 && !missing.isEmpty() && System.nanoTime() < deadline) { if (dispatch == null) { dispatch = dispatchContext(ctx, level); } - dispatchMissingBuilds(dispatch, chunkSource, dispatchSlots, pcx, psy, pcz, deadline); + int dispatched = dispatchMissingBuilds(dispatch, chunkSource, interactiveSlots, pcx, psy, pcz, + deadline, true); + interactiveSlots -= dispatched; + dispatchSlots -= dispatched; + } + int normalSlots = Math.min(dispatchSlots, + Math.max(0, maxInflight() - taskTracker.outstanding())); + if (normalSlots > 0 && !missing.isEmpty() && System.nanoTime() < deadline) { + if (dispatch == null) { + dispatch = dispatchContext(ctx, level); + } + dispatchMissingBuilds(dispatch, chunkSource, normalSlots, pcx, psy, pcz, deadline, false); } } flushLightHierarchyUpdate(ctx); @@ -652,6 +690,12 @@ private void recordTerrainTelemetry(RtContext ctx) { RtFrameStats.FRAME.count("terrainResidentSections", resident.size()); RtFrameStats.FRAME.count("terrainGpuQueueDepth", ctx.gpuExecutor().queuedBuilds()); RtFrameStats.FRAME.count("terrainBuildsSubmitted", buildsSubmittedSinceSample.getAndSet(0L)); + RtFrameStats.FRAME.count("terrainBuildsPublished", buildsPublishedSinceSample.getAndSet(0L)); + RtFrameStats.FRAME.count("terrainInteractiveOutstanding", interactiveOutstanding.get()); + RtFrameStats.FRAME.count("terrainInteractiveSubmitted", + interactiveSubmittedSinceSample.getAndSet(0L)); + RtFrameStats.FRAME.count("terrainInteractivePublished", + interactivePublishedSinceSample.getAndSet(0L)); RtFrameStats.FRAME.count("terrainBuildLatencyNanos", lastBuildLatencyNanos); RtFrameStats.FRAME.count("terrainActiveCompactionQueries", RtAccel.activeTerrainCompactionQueries()); } @@ -677,6 +721,8 @@ private void rebuildDesiredWindow(ClientChunkCache chunkSource, int pcx, int pcz loadedColumns.clear(); missing.clear(); missingIndex.clear(); + interactiveMissing.clear(); + completionFairness.reset(); for (int scx = pcx - radius; scx <= pcx + radius; scx++) { for (int scz = pcz - radius; scz <= pcz + radius; scz++) { @@ -855,24 +901,15 @@ private boolean handleDirtySection(long key, long dirtyGroup) { setQueuedGroup(key, dirtyGroup); return true; } else { - return enqueueMissing(key, dirtyGroup); + return enqueueMissing(key, dirtyGroup, true); } } private boolean enqueueMissingIfNeeded(long key) { - if (resident.containsKey(key) || empty.contains(key) || taskTracker.containsCurrent(key)) { - return false; - } - if (missingIndex.get(key) != NO_MISSING_INDEX) { - return false; - } - setQueuedGroup(key, NO_DIRTY_GROUP); - missingIndex.put(key, missing.size()); - missing.add(key); - return true; + return enqueueMissing(key, NO_DIRTY_GROUP, false); } - private boolean enqueueMissing(long key, long dirtyGroup) { + private boolean enqueueMissing(long key, long dirtyGroup, boolean interactive) { if (resident.containsKey(key) || empty.contains(key) || taskTracker.containsCurrent(key)) { return false; } @@ -882,6 +919,9 @@ private boolean enqueueMissing(long key, long dirtyGroup) { missingIndex.put(key, missing.size()); missing.add(key); } + if (interactive) { + interactiveMissing.mark(key, true); + } setQueuedGroup(key, dirtyGroup); return true; } @@ -913,6 +953,7 @@ private boolean isQueuedAnywhere(long key) { /** Remove an unsorted missing entry in O(1) by moving the last entry into its slot. */ private void removeMissing(long key) { + interactiveMissing.remove(key); int index = missingIndex.remove(key); if (index == NO_MISSING_INDEX) { return; @@ -1049,10 +1090,10 @@ private static DispatchContext dispatchContext(RtContext ctx, ClientLevel level) * {@link RenderRegionCache} dedupes {@code SectionCopy}s, so after a column's first snapshot the rest * are nearly free. */ - private void dispatchMissingBuilds(DispatchContext dispatch, ClientChunkCache chunkSource, int remaining, - int pcx, int psy, int pcz, long deadline) { + private int dispatchMissingBuilds(DispatchContext dispatch, ClientChunkCache chunkSource, int remaining, + int pcx, int psy, int pcz, long deadline, boolean interactiveOnly) { if (missing.isEmpty() || remaining <= 0) { - return; + return 0; } // Over-collect 2x the remaining slots so candidates skipped for unready neighbour chunks (they cluster at // the window edge) don't leave dispatch slots idle. @@ -1063,6 +1104,9 @@ private void dispatchMissingBuilds(DispatchContext dispatch, ClientChunkCache ch int heapSize = 0; for (int read = 0, n = missing.size(); read < n && System.nanoTime() < deadline; read++) { long key = missing.getLong(read); + if (interactiveMissing.contains(key) != interactiveOnly) { + continue; + } // rank = columnDist²(16+) | |Δy|(0..15): column-major nearest-first. long rank = distanceRank(key, pcx, psy, pcz); if (heapSize < k) { @@ -1081,6 +1125,7 @@ private void dispatchMissingBuilds(DispatchContext dispatch, ClientChunkCache ch long q = heapKey[0]; heapKey[0] = heapKey[end]; heapKey[end] = q; siftDown(heapRank, heapKey, end, 0); } + int dispatched = 0; for (int i = 0; i < heapSize && remaining > 0 && System.nanoTime() < deadline; i++) { long key = heapKey[i]; if (!desired.contains(key) || resident.containsKey(key) || empty.contains(key) @@ -1097,8 +1142,10 @@ private void dispatchMissingBuilds(DispatchContext dispatch, ClientChunkCache ch } removeMissing(key); remaining--; - dispatchSectionBuild(dispatch, key, sx, sectionY(key), sz); + dispatchSectionBuild(dispatch, key, sx, sectionY(key), sz, interactiveOnly); + dispatched++; } + return dispatched; } /** Max-heap sift-up on parallel (rank, key) arrays — worst candidate at the root. */ @@ -1195,7 +1242,7 @@ private int dispatchReextract(DispatchContext dispatch, ClientChunkCache chunkSo SectionGeom g = resident.get(key); queuedReextract.remove(key); removeUnsorted(reextract, reextract.indexOf(key)); - dispatchSectionBuild(dispatch, key, g.sx >> 4, g.sy >> 4, g.sz >> 4); + dispatchSectionBuild(dispatch, key, g.sx >> 4, g.sy >> 4, g.sz >> 4, true); remaining--; dispatched++; } @@ -1219,7 +1266,8 @@ private static void removeUnsorted(LongArrayList queue, int index) { } /** Snapshot one section and dispatch its complete worker → GPU build lifecycle. */ - private void dispatchSectionBuild(DispatchContext dispatch, long key, int sx, int sy, int sz) { + private void dispatchSectionBuild(DispatchContext dispatch, long key, int sx, int sy, int sz, + boolean interactive) { RtFrameStats.FRAME.count("sectionsSnapshotted", 1); long snapshotStart = benchmarkTelemetryEnabled ? System.nanoTime() : 0L; RtSectionSnapshots.Region region = snapshots.createRegion(dispatch.level(), sx, sy, sz); @@ -1235,15 +1283,18 @@ private void dispatchSectionBuild(DispatchContext dispatch, long key, int sx, in RtMaterialRegistry.Snapshot materialSnapshot = RtMaterialRegistry.INSTANCE.requireSnapshot(); TerrainTaskTracker.Ticket ticket = taskTracker.accept(key, token); SectionTask task = new SectionTask(ticket, key, token, sx << 4, sy << 4, sz << 4, dirtyGroup, - terrainEpoch, materialSnapshot.epoch()); + terrainEpoch, materialSnapshot.epoch(), interactive); if (dirtyGroup != NO_DIRTY_GROUP) { inFlightDirtyGroup.put(key, dirtyGroup); } else { inFlightDirtyGroup.remove(key); } beginActiveTask(); + if (interactive) { + interactiveOutstanding.incrementAndGet(); + } try { - RtWorkerPool.INSTANCE.submit(() -> { + RtWorkerPool.INSTANCE.submit(interactive, () -> { long cpuStart = benchmarkTelemetryEnabled ? System.nanoTime() : 0L; if (benchmarkTelemetryEnabled) benchmarkWorkerActive.incrementAndGet(); try { @@ -1294,6 +1345,9 @@ private void dispatchSectionBuild(DispatchContext dispatch, long key, int sx, in taskTracker.cancelCurrent(key); taskTracker.retire(ticket); inFlightDirtyGroup.remove(key); + if (interactive) { + interactiveOutstanding.decrementAndGet(); + } finishActiveTask(); throw t; } @@ -1302,9 +1356,7 @@ private void dispatchSectionBuild(DispatchContext dispatch, long key, int sx, in /** Build/query, then compact-copy a terrain BLAS before making it eligible for publication. */ private void submitTerrainBuild(RtContext ctx, SectionTask task, PreparedSection prepared) { if (benchmarkTelemetryEnabled) task.gpuStartedNanos = System.nanoTime(); - buildsSubmitted.incrementAndGet(); - buildsSubmittedSinceSample.incrementAndGet(); - ctx.gpuExecutor().submit( + ctx.gpuExecutor().submit(task.interactive, () -> !isTaskCurrent(task), cmd -> { RtSectionBuilder.recordUpload(cmd, prepared); @@ -1327,11 +1379,16 @@ private void submitTerrainBuild(RtContext ctx, SectionTask task, PreparedSection return; } // The immutable source BLAS is already complete and traceable. Publish it directly; - // the optional compact-size query/copy phase has produced repeatable device loss on - // the current NVIDIA driver while startup terrain builds overlap graphics work. + // the optional compact-size query/copy phase is intentionally not part of publication. prepared.releaseBuildInputs(); completeTask(task, prepared, build, null); }); + buildsSubmitted.incrementAndGet(); + buildsSubmittedSinceSample.incrementAndGet(); + if (task.interactive) { + interactiveSubmittedTotal.incrementAndGet(); + interactiveSubmittedSinceSample.incrementAndGet(); + } } private void completeTask(SectionTask task, PreparedSection prepared, RtGpuExecutor.Build build, Throwable failure) { @@ -1339,6 +1396,9 @@ private void completeTask(SectionTask task, PreparedSection prepared, RtGpuExecu } private void completeTask(SectionResult result) { + if (!result.task().terminalCompleted.compareAndSet(false, true)) { + return; + } try { long gpuStarted = result.task().gpuStartedNanos; if (gpuStarted != 0L) { @@ -1346,7 +1406,12 @@ private void completeTask(SectionResult result) { benchmarkGpuNanos.add(System.nanoTime() - gpuStarted); benchmarkGpuCompleted.increment(); } - completedBuilds.add(result); + if (result.task().interactive) { + interactiveOutstanding.decrementAndGet(); + interactiveCompletedBuilds.add(result); + } else { + completedBuilds.add(result); + } } finally { finishActiveTask(); } @@ -1386,6 +1451,10 @@ private void awaitActiveTasks() { } } + private SectionResult pollCompletedBuild() { + return completionFairness.poll(interactiveCompletedBuilds, completedBuilds); + } + /** * Publish terminal worker/executor results (up to the configured result count per pass). A task * whose ticket is no longer current is stale and its unpublished native result is @@ -1395,7 +1464,7 @@ private void drainCompletedBuilds(RtContext ctx, List prepared, int resultCap, long deadline) { int remaining = resultCap; while (remaining > 0 && System.nanoTime() < deadline) { - SectionResult result = completedBuilds.poll(); + SectionResult result = pollCompletedBuild(); if (result == null) { break; } @@ -1413,7 +1482,7 @@ private void drainCompletedBuilds(RtContext ctx, List prepared, taskTracker.cancelCurrent(task.key); long staleGroup = inFlightDirtyGroup.remove(task.key); if (staleGroup != NO_DIRTY_GROUP) cancelDirtyGroup(staleGroup); - enqueueMissingIfNeeded(task.key); + enqueueMissing(task.key, NO_DIRTY_GROUP, task.interactive); if (!materialValid) { RtFrameStats.FRAME.count("terrainMaterialEpochRejects", 1); } @@ -1428,6 +1497,8 @@ private void drainCompletedBuilds(RtContext ctx, List prepared, } if (dirtyGroup != NO_DIRTY_GROUP) { cancelDirtyGroup(dirtyGroup); + } else { + requeueFailedTask(task); } throw new RuntimeException("RT terrain section build failed for section " + (task.sox >> 4) + "," + (task.soy >> 4) + "," + (task.soz >> 4), @@ -1447,7 +1518,7 @@ private void drainCompletedBuilds(RtContext ctx, List prepared, throw new RuntimeException("RT terrain GPU build failed for section " + (task.sox >> 4) + "," + (task.soy >> 4) + "," + (task.soz >> 4), t); } - publicationTickets.put(built, task.ticket); + publicationTickets.put(built, new PublicationOwner(task.ticket, task.interactive)); publicationPending = true; } if (dirtyGroup != NO_DIRTY_GROUP && dirtyGroups.containsKey(dirtyGroup)) { @@ -1494,6 +1565,21 @@ private void destroyCompletedResult(RtContext ctx, SectionResult result) { destroyPreparedSection(result.prepared()); } + private void requeueFailedTask(SectionTask task) { + taskTracker.cancelCurrent(task.key); + if (!desired.contains(task.key)) { + return; + } + if (resident.containsKey(task.key)) { + if (queuedReextract.add(task.key)) { + reextract.add(task.key); + } + } else { + empty.remove(task.key); + enqueueMissing(task.key, NO_DIRTY_GROUP, true); + } + } + private void completeDirtyGroupMember(DirtyGroup group, List prepared, List removed) { if (--group.remaining > 0) { return; @@ -1542,7 +1628,7 @@ private void cancelDirtyGroup(long groupId) { } } else { empty.remove(key); - enqueueMissingIfNeeded(key); + enqueueMissing(key, NO_DIRTY_GROUP, true); } } } @@ -1572,9 +1658,9 @@ private void destroyPreparedSection(PreparedSection ps) { } private void retirePublicationTicket(PreparedSection ps) { - TerrainTaskTracker.Ticket ticket = publicationTickets.remove(ps); - if (ticket != null) { - taskTracker.retire(ticket); + PublicationOwner owner = publicationTickets.remove(ps); + if (owner != null) { + taskTracker.retire(owner.ticket()); } } @@ -1614,9 +1700,11 @@ private static final class SectionTask { final long dirtyGroup; final long terrainEpoch; final long materialEpoch; + final boolean interactive; + final AtomicBoolean terminalCompleted = new AtomicBoolean(); volatile long gpuStartedNanos; SectionTask(TerrainTaskTracker.Ticket ticket, long key, long token, int sox, int soy, int soz, long dirtyGroup, - long terrainEpoch, long materialEpoch) { + long terrainEpoch, long materialEpoch, boolean interactive) { this.ticket = ticket; this.key = key; this.token = token; @@ -1626,9 +1714,13 @@ private static final class SectionTask { this.dirtyGroup = dirtyGroup; this.terrainEpoch = terrainEpoch; this.materialEpoch = materialEpoch; + this.interactive = interactive; } } + private record PublicationOwner(TerrainTaskTracker.Ticket ticket, boolean interactive) { + } + private record SectionResult(SectionTask task, PreparedSection prepared, RtGpuExecutor.Build build, Throwable failure) { } @@ -1668,7 +1760,7 @@ private void applyBuildChanges(RtContext ctx, List prepared, Li } for (PreparedSection ps : prepared) { - TerrainTaskTracker.Ticket publicationTicket = publicationTickets.remove(ps); + PublicationOwner publicationOwner = publicationTickets.remove(ps); try { SectionGeom g = new SectionGeom(ps.key(), ps.uvs(), ps.material(), ps.blas().accel, ps.triBase(), ps.sx(), ps.sy(), ps.sz(), ps.lights()); @@ -1703,11 +1795,16 @@ private void applyBuildChanges(RtContext ctx, List prepared, Li published.add(ps.key()); if (benchmarkTelemetryEnabled) benchmarkPublished.increment(); buildsPublished.incrementAndGet(); + buildsPublishedSinceSample.incrementAndGet(); + if (publicationOwner != null && publicationOwner.interactive()) { + interactivePublishedTotal.incrementAndGet(); + interactivePublishedSinceSample.incrementAndGet(); + } RtFrameStats.FRAME.count("terrainBuildsPublished", 1); RtFrameStats.FRAME.count("sectionsUploaded", 1); } finally { - if (publicationTicket != null) { - taskTracker.retire(publicationTicket); + if (publicationOwner != null) { + taskTracker.retire(publicationOwner.ticket()); } } } @@ -1823,7 +1920,7 @@ private void drainTasksForClear(RtContext ctx) { lightGrid.awaitIdle(); Throwable failure = null; SectionResult result; - while ((result = completedBuilds.poll()) != null) { + while ((result = pollCompletedBuild()) != null) { try { if (result.prepared() != null) { destroyPreparedSection(result.prepared()); @@ -1837,6 +1934,10 @@ private void drainTasksForClear(RtContext ctx) { } taskTracker.cancelAllCurrent(); inFlightDirtyGroup.clear(); + if (interactiveOutstanding.get() != 0L) { + throw new IllegalStateException("interactive terrain tasks remain after join: " + + interactiveOutstanding.get()); + } if (failure != null) { throw new RuntimeException("RT terrain worker/build failed during teardown", failure); } @@ -1871,6 +1972,8 @@ private void clear(RtContext ctx, boolean shutdown) { loadedColumns.clear(); missing.clear(); missingIndex.clear(); + interactiveMissing.clear(); + completionFairness.reset(); queuedDirtyGroup.clear(); reextract.clear(); queuedReextract.clear(); @@ -1955,7 +2058,7 @@ private void clearAsync(RtContext ctx) { } prepared.clear(); SectionResult completed; - while ((completed = completedBuilds.poll()) != null) { + while ((completed = pollCompletedBuild()) != null) { try { if (completed.prepared() != null) { oldPrepared.add(completed.prepared()); @@ -1978,6 +2081,8 @@ private void clearAsync(RtContext ctx) { loadedColumns.clear(); missing.clear(); missingIndex.clear(); + interactiveMissing.clear(); + completionFairness.reset(); queuedDirtyGroup.clear(); reextract.clear(); queuedReextract.clear(); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPool.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPool.java index 97d696a7..0a1f22e3 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPool.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPool.java @@ -2,12 +2,14 @@ import dev.comfyfluffy.caustica.CausticaConfig; import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.rt.TerrainJobOrder; -import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; /** * Shared daemon thread pool for CPU-heavy RT work that must stay off the render thread — terrain @@ -24,45 +26,169 @@ public final class RtWorkerPool { public static final RtWorkerPool INSTANCE = new RtWorkerPool(); private ThreadPoolExecutor exec; + private boolean shuttingDown; - private RtWorkerPool() {} + private final AtomicLong nextSequence = new AtomicLong(); + + /** + * Package-private so lifecycle behavior can be tested without mutating + * the process-wide singleton. + */ + RtWorkerPool() {} private static int resolveThreads() { return CausticaConfig.Rt.WORKER_THREADS.value(); } - private synchronized ThreadPoolExecutor executor() { + /** + * The caller must hold this object's monitor. + */ + private ThreadPoolExecutor executorLocked() { + if (!Thread.holdsLock(this)) { + throw new AssertionError("RtWorkerPool lock is not held"); + } + + if (shuttingDown) { + throw new IllegalStateException("RT worker pool is shutting down"); + } + if (exec == null) { int threads = resolveThreads(); + ThreadFactory factory = new ThreadFactory() { private final AtomicInteger n = new AtomicInteger(); + @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r, "rt-worker-" + n.incrementAndGet()); - t.setDaemon(true); - t.setPriority(Thread.NORM_PRIORITY - 1); - return t; + public Thread newThread(Runnable runnable) { + Thread thread = + new Thread(runnable, "rt-worker-" + n.incrementAndGet()); + + thread.setDaemon(true); + thread.setPriority(Thread.NORM_PRIORITY - 1); + return thread; } }; - ThreadPoolExecutor e = new ThreadPoolExecutor(threads, threads, 30, TimeUnit.SECONDS, - new LinkedBlockingQueue<>(), factory); - e.allowCoreThreadTimeOut(true); - exec = e; - CausticaMod.LOGGER.info("RT worker pool started with {} thread(s)", threads); + + ThreadPoolExecutor created = new ThreadPoolExecutor( + threads, + threads, + 30L, + TimeUnit.SECONDS, + new PriorityBlockingQueue<>(), + factory + ); + + created.allowCoreThreadTimeOut(true); + exec = created; + + CausticaMod.LOGGER.info( + "RT worker pool started with {} thread(s)", + threads + ); } + return exec; } - /** Submit worker-owned RT preparation; completion is delivered by the task itself. */ + /** Submit worker-owned RT preparation; completion is delivered by the task. */ public void submit(Runnable job) { - executor().execute(job); + submit(false, job); + } + + /** Submit terrain work with bounded interactive priority. */ + public void submit(boolean interactive, Runnable job) { + if (job == null) { + throw new NullPointerException("job"); + } + + synchronized (this) { + /* + * Calling execute while holding the same lock used by shutdown closes + * the executor-selection/shutdown race. Once this returns, the task + * has either been accepted or an exception has been delivered to the + * caller. + */ + executorLocked().execute( + new WorkerTask( + interactive, + nextSequence.incrementAndGet(), + job + ) + ); + } } - /** Stop all workers and drop queued jobs. Safe to call when never started. */ - public synchronized void shutdown() { - if (exec != null) { - exec.shutdownNow(); - exec = null; + synchronized boolean isShuttingDown() { + return shuttingDown; + } + + /** + * Stop all workers after joining every accepted job. + * Safe to call when never started and safe for concurrent callers. + */ + public void shutdown() { + final ThreadPoolExecutor stopping; + + synchronized (this) { + if (exec == null) { + return; + } + + stopping = exec; + + if (!shuttingDown) { + /* + * No submitter can enter while this monitor is held. + */ + stopping.shutdown(); + shuttingDown = true; + } + } + + boolean interrupted = false; + + while (!stopping.isTerminated()) { + try { + stopping.awaitTermination( + Long.MAX_VALUE, + TimeUnit.NANOSECONDS + ); + } catch (InterruptedException ignored) { + /* + * Accepted terrain jobs own terminal callbacks, so teardown must + * still drain them. Restore interruption after the drain. + */ + interrupted = true; + } + } + + synchronized (this) { + if (exec == stopping) { + exec = null; + shuttingDown = false; + notifyAll(); + } + } + + if (interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while stopping RT worker pool" + ); + } + } + + private record WorkerTask(boolean interactive, long sequence, Runnable delegate) + implements Runnable, Comparable { + @Override + public int compareTo(WorkerTask other) { + return TerrainJobOrder.compare(interactive, sequence, + other.interactive, other.sequence); + } + + @Override + public void run() { + delegate.run(); } } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/TerrainCompletionFairness.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/TerrainCompletionFairness.java new file mode 100644 index 00000000..ff017110 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/TerrainCompletionFairness.java @@ -0,0 +1,35 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import java.util.Queue; + +/** Limits interactive completion publication to preserve ordinary terrain progress. */ +final class TerrainCompletionFairness { + static final int MAX_INTERACTIVE_STREAK = 4; + private int interactiveStreak; + + T poll(Queue interactiveQueue, Queue normalQueue) { + boolean normalAvailable = !normalQueue.isEmpty(); + if (!interactiveQueue.isEmpty() + && (!normalAvailable || interactiveStreak < MAX_INTERACTIVE_STREAK)) { + T interactive = interactiveQueue.poll(); + if (interactive != null) { + interactiveStreak = Math.min(MAX_INTERACTIVE_STREAK, interactiveStreak + 1); + return interactive; + } + } + T normal = normalQueue.poll(); + if (normal != null) { + interactiveStreak = 0; + return normal; + } + T interactive = interactiveQueue.poll(); + if (interactive != null) { + interactiveStreak = Math.min(MAX_INTERACTIVE_STREAK, interactiveStreak + 1); + } + return interactive; + } + + void reset() { + interactiveStreak = 0; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/TerrainMissingPriority.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/TerrainMissingPriority.java new file mode 100644 index 00000000..ffc9c29a --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/TerrainMissingPriority.java @@ -0,0 +1,26 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +/** Tracks which missing sections were promoted by a dirty edit. */ +final class TerrainMissingPriority { + private final LongOpenHashSet interactive = new LongOpenHashSet(); + + void mark(long key, boolean interactive) { + if (interactive) { + this.interactive.add(key); + } + } + + boolean contains(long key) { + return interactive.contains(key); + } + + void remove(long key) { + interactive.remove(key); + } + + void clear() { + interactive.clear(); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigSceneMigrationTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigSceneMigrationTest.java index e173dc4a..1a5111da 100644 --- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigSceneMigrationTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigSceneMigrationTest.java @@ -373,14 +373,14 @@ void schema13DoesNotReplaceNrdBackend() { } @Test - void schema14UnifiesCelestialLightBouncesIntoMaxBounces() { + void schema14RemovesRetiredCelestialLightBounces() { CommentedConfig defaults = CommentedConfig.inMemory(); defaults.set("config-version", 13); defaults.set("composite.max-bounces", 8.0); defaults.set("composite.celestial-light-bounces", 4.0); assertTrue(CausticaConfig.migrateLegacySceneConfig(defaults)); assertEquals(CausticaConfig.CONFIG_SCHEMA_VERSION, ((Number) defaults.get("config-version")).intValue()); - assertEquals(64.0, ((Number) defaults.get("composite.max-bounces")).doubleValue()); + assertEquals(8.0, ((Number) defaults.get("composite.max-bounces")).doubleValue()); assertFalse(defaults.contains("composite.celestial-light-bounces")); CommentedConfig custom = CommentedConfig.inMemory(); @@ -393,6 +393,53 @@ void schema14UnifiesCelestialLightBouncesIntoMaxBounces() { assertFalse(custom.contains("composite.celestial-light-bounces")); } + @Test + void schemaFourteenGeneratedDefaultsAreCorrected() { + CommentedConfig config = CommentedConfig.inMemory(); + config.set("config-version", 14); + config.set("composite.max-bounces", 64); + config.set("lights.ris-candidates", 8); + + assertTrue(CausticaConfig.migrateLegacySceneConfig(config)); + assertEquals(8, ((Number) config.get("composite.max-bounces")).intValue()); + assertEquals(0, ((Number) config.get("lights.ris-candidates")).intValue()); + assertEquals(15, ((Number) config.get("config-version")).intValue()); + } + + @Test + void schemaFourteenCustomValuesSurvive() { + CommentedConfig config = CommentedConfig.inMemory(); + config.set("config-version", 14); + config.set("composite.max-bounces", 24); + config.set("lights.ris-candidates", 4); + + assertTrue(CausticaConfig.migrateLegacySceneConfig(config)); + assertEquals(24, ((Number) config.get("composite.max-bounces")).intValue()); + assertEquals(4, ((Number) config.get("lights.ris-candidates")).intValue()); + } + + @Test + void directSchemaThirteenCustomSixtyFourSurvives() { + CommentedConfig config = CommentedConfig.inMemory(); + config.set("config-version", 13); + config.set("composite.max-bounces", 64); + config.set("lights.ris-candidates", 8); + + assertTrue(CausticaConfig.migrateLegacySceneConfig(config)); + assertEquals(64, ((Number) config.get("composite.max-bounces")).intValue()); + assertEquals(8, ((Number) config.get("lights.ris-candidates")).intValue()); + } + + @Test + void directSchemaThirteenDefaultEightStaysEight() { + CommentedConfig config = CommentedConfig.inMemory(); + config.set("config-version", 13); + config.set("composite.max-bounces", 8); + + assertTrue(CausticaConfig.migrateLegacySceneConfig(config)); + assertEquals(8, ((Number) config.get("composite.max-bounces")).intValue()); + } + @Test void schemaElevenPreservesUserCustomizations() { CommentedConfig custom = CommentedConfig.inMemory(); diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtAsLaneModeTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtAsLaneModeTest.java new file mode 100644 index 00000000..bce453ee --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtAsLaneModeTest.java @@ -0,0 +1,31 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class RtAsLaneModeTest { + @Test + void autoUsesSerializedMode() { + assertEquals(RtDeviceBringup.AccelerationStructureLaneMode.SERIALIZED, + RtDeviceBringup.requestedAsLaneMode("auto")); + } + + @Test + void serializedUsesSerializedMode() { + assertEquals(RtDeviceBringup.AccelerationStructureLaneMode.SERIALIZED, + RtDeviceBringup.requestedAsLaneMode("serialized")); + } + + @Test + void overlapIsExplicitlyAvailable() { + assertEquals(RtDeviceBringup.AccelerationStructureLaneMode.OVERLAP, + RtDeviceBringup.requestedAsLaneMode("overlap")); + } + + @Test + void invalidModeFallsBackToSerialized() { + assertEquals(RtDeviceBringup.AccelerationStructureLaneMode.SERIALIZED, + RtDeviceBringup.requestedAsLaneMode("unknown")); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtDeviceBringupPolicyTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtDeviceBringupPolicyTest.java new file mode 100644 index 00000000..41d61922 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtDeviceBringupPolicyTest.java @@ -0,0 +1,58 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RtDeviceBringupPolicyTest { + @Test + void portableNvidiaAutoSuppressesOmm() { + assertFalse(RtDeviceBringup.effectiveOmmRequested( + true, RtDeviceBringup.SerBackend.NONE, true, "auto")); + } + + @Test + void serNvidiaAutoKeepsOmmAvailable() { + assertTrue(RtDeviceBringup.effectiveOmmRequested( + true, RtDeviceBringup.SerBackend.EXT, true, "auto")); + } + + @Test + void explicitOmmOnOverridesPortableProfile() { + assertTrue(RtDeviceBringup.effectiveOmmRequested( + true, RtDeviceBringup.SerBackend.NONE, true, "on")); + } + + @Test + void explicitOmmOffWinsOnModernNvidia() { + assertFalse(RtDeviceBringup.effectiveOmmRequested( + true, RtDeviceBringup.SerBackend.EXT, true, "off")); + } + + @Test + void nonNvidiaAutoKeepsSupportedOmmAvailable() { + assertTrue(RtDeviceBringup.effectiveOmmRequested( + false, RtDeviceBringup.SerBackend.NONE, true, "auto")); + } + + @Test + void userOmmGateDisablesEveryPolicy() { + assertFalse(RtDeviceBringup.effectiveOmmRequested( + false, RtDeviceBringup.SerBackend.EXT, false, "on")); + } + + @Test + void missingSerDoesNotMakeRequiredRtUnsupported() { + assertTrue(new RtDeviceBringup.FeatureSupport( + List.of(), RtDeviceBringup.SerBackend.NONE, false, false).supportsRt()); + } + + @Test + void missingRequiredFeatureStillMakesRtUnsupported() { + assertFalse(new RtDeviceBringup.FeatureSupport( + List.of("rayQuery"), RtDeviceBringup.SerBackend.NONE, false, false).supportsRt()); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtGpuExecutorTimelineTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtGpuExecutorTimelineTest.java new file mode 100644 index 00000000..c471e059 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtGpuExecutorTimelineTest.java @@ -0,0 +1,36 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +final class RtGpuExecutorTimelineTest { + @Test + void priorityReorderingAssignsAndSignalsExecutionMaximum() { + RtGpuExecutor.Build streamingA = new RtGpuExecutor.Build(); + RtGpuExecutor.Build streamingB = new RtGpuExecutor.Build(); + RtGpuExecutor.Build interactiveC = new RtGpuExecutor.Build(); + record Candidate(boolean interactive, long sequence, RtGpuExecutor.Build build) {} + List candidates = new ArrayList<>(List.of( + new Candidate(false, 1L, streamingA), + new Candidate(false, 2L, streamingB), + new Candidate(true, 3L, interactiveC))); + candidates.sort((left, right) -> TerrainJobOrder.compare( + left.interactive(), left.sequence(), right.interactive(), right.sequence())); + long signalValue = RtGpuExecutor.assignTimelineValues( + candidates.stream().map(Candidate::build).toList(), new AtomicLong()); + + assertEquals(1L, interactiveC.value()); + assertEquals(2L, streamingA.value()); + assertEquals(3L, streamingB.value()); + assertEquals(3L, signalValue); + + RtGpuExecutor.Build cancelled = new RtGpuExecutor.Build(); + assertFalse(cancelled.submitted()); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/TerrainJobOrderTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/TerrainJobOrderTest.java new file mode 100644 index 00000000..9bf1ef55 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/TerrainJobOrderTest.java @@ -0,0 +1,24 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class TerrainJobOrderTest { + @Test + void interactiveTaskJumpsCurrentStreamingBacklog() { + long interactiveSequence = 33L; + for (long streamingSequence = 1L; streamingSequence <= 32L; streamingSequence++) { + assertTrue(TerrainJobOrder.compare(true, interactiveSequence, + false, streamingSequence) < 0); + } + } + + @Test + void oldStreamingTaskEventuallyBeatsNewInteractiveTasks() { + long oldStreamingSequence = 1L; + long sufficientlyNewInteractive = 1L + TerrainJobOrder.INTERACTIVE_SEQUENCE_BOOST; + assertTrue(TerrainJobOrder.compare(false, oldStreamingSequence, + true, sufficientlyNewInteractive) < 0); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/OmmDeviceGateContractTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/OmmDeviceGateContractTest.java index 234e4403..d8b30822 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/OmmDeviceGateContractTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/OmmDeviceGateContractTest.java @@ -1,18 +1,16 @@ package dev.comfyfluffy.caustica.rt.pipeline; +import dev.comfyfluffy.caustica.CausticaConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.nio.file.Files; -import java.nio.file.Path; import org.junit.jupiter.api.Test; final class OmmDeviceGateContractTest { @Test - void persistedOmmSettingControlsDeviceExtensionEnablement() throws Exception { - String bringup = Files.readString(Path.of( - "src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java")); - - assertTrue(bringup.contains("return CausticaConfig.Rt.Omm.ENABLED.value();")); - assertTrue(!bringup.matches("(?s).*private static boolean ommRequested\\(\\) \\{\\s*return true;.*")); + void ommConfigurationDefaultsAreExplicit() { + assertTrue(CausticaConfig.Rt.Omm.ENABLED.defaultValue()); + assertEquals("auto", CausticaConfig.Rt.Compatibility.OMM_MODE.defaultValue()); } } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/TerrainTelemetryContractTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/TerrainTelemetryContractTest.java index 4c91094e..40eb09de 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/TerrainTelemetryContractTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/TerrainTelemetryContractTest.java @@ -18,7 +18,9 @@ void everyTerrainCounterUsedByTheSchedulerIsRegisteredAndBridgeLatencyIsHonest() "src/main/java/dev/comfyfluffy/caustica/client/CausticaDebugBridge.java")); for (String counter : new String[] {"terrainMaterialEpochRejects", "terrainOutstandingTasks", - "terrainCancelledTasks", "terrainDiscardedBuilds", "terrainBuildLatencyNanos"}) { + "terrainCancelledTasks", "terrainDiscardedBuilds", "terrainBuildLatencyNanos", + "terrainInteractiveOutstanding", "terrainInteractiveSubmitted", + "terrainInteractivePublished"}) { assertTrue(terrain.contains('"' + counter + '"')); assertTrue(stats.contains('"' + counter + '"')); } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPoolLifecycleTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPoolLifecycleTest.java new file mode 100644 index 00000000..f6021699 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtWorkerPoolLifecycleTest.java @@ -0,0 +1,131 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +final class RtWorkerPoolLifecycleTest { + + @Test + void shutdownDrainsAcceptedWorkAndRejectsNewWorkWhileStopping() + throws Exception { + + RtWorkerPool pool = new RtWorkerPool(); + + CountDownLatch taskStarted = new CountDownLatch(1); + CountDownLatch releaseTask = new CountDownLatch(1); + AtomicInteger completed = new AtomicInteger(); + AtomicReference shutdownFailure = + new AtomicReference<>(); + + pool.submit(false, () -> { + taskStarted.countDown(); + awaitUninterruptibly(releaseTask); + completed.incrementAndGet(); + }); + + assertTrue( + taskStarted.await(5L, TimeUnit.SECONDS), + "worker task did not start" + ); + + Thread stopper = new Thread(() -> { + try { + pool.shutdown(); + } catch (Throwable throwable) { + shutdownFailure.set(throwable); + } + }, "rt-worker-pool-shutdown-test"); + + stopper.start(); + + try { + awaitShuttingDown(pool); + + assertThrows( + IllegalStateException.class, + () -> pool.submit(false, () -> { + throw new AssertionError( + "submission during shutdown was executed" + ); + }) + ); + } finally { + releaseTask.countDown(); + } + + stopper.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse( + stopper.isAlive(), + "worker-pool shutdown did not complete" + ); + + assertNull( + shutdownFailure.get(), + "worker-pool shutdown failed" + ); + + assertEquals( + 1, + completed.get(), + "accepted work was not drained exactly once" + ); + + /* + * Preserve the previous restartable behavior after a completed + * shutdown. + */ + CountDownLatch restarted = new CountDownLatch(1); + pool.submit(false, restarted::countDown); + + assertTrue( + restarted.await(5L, TimeUnit.SECONDS), + "worker pool did not restart after completed shutdown" + ); + + pool.shutdown(); + } + + private static void awaitShuttingDown(RtWorkerPool pool) + throws InterruptedException { + + long deadline = + System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + + while (!pool.isShuttingDown()) { + if (System.nanoTime() >= deadline) { + fail("worker pool did not enter shutdown state"); + } + + Thread.sleep(1L); + } + } + + private static void awaitUninterruptibly(CountDownLatch latch) { + boolean interrupted = false; + + while (true) { + try { + latch.await(); + break; + } catch (InterruptedException ignored) { + interrupted = true; + } + } + + if (interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/terrain/TerrainCompletionFairnessTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/TerrainCompletionFairnessTest.java new file mode 100644 index 00000000..ec6e8990 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/TerrainCompletionFairnessTest.java @@ -0,0 +1,48 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ConcurrentLinkedQueue; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class TerrainCompletionFairnessTest { + @Test + void normalCompletionWinsAfterFourInteractiveResults() { + TerrainCompletionFairness fairness = new TerrainCompletionFairness<>(); + ConcurrentLinkedQueue interactive = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue normal = new ConcurrentLinkedQueue<>(); + interactive.add("i1"); + interactive.add("i2"); + interactive.add("i3"); + interactive.add("i4"); + interactive.add("i5"); + normal.add("n1"); + + assertEquals("i1", fairness.poll(interactive, normal)); + assertEquals("i2", fairness.poll(interactive, normal)); + assertEquals("i3", fairness.poll(interactive, normal)); + assertEquals("i4", fairness.poll(interactive, normal)); + assertEquals("n1", fairness.poll(interactive, normal)); + } + + @Test + void resetClearsPriorInteractiveStreak() { + TerrainCompletionFairness fairness = new TerrainCompletionFairness<>(); + ConcurrentLinkedQueue interactive = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue normal = new ConcurrentLinkedQueue<>(); + interactive.add("i1"); + interactive.add("i2"); + interactive.add("i3"); + interactive.add("i4"); + interactive.add("i5"); + + assertEquals("i1", fairness.poll(interactive, normal)); + assertEquals("i2", fairness.poll(interactive, normal)); + assertEquals("i3", fairness.poll(interactive, normal)); + assertEquals("i4", fairness.poll(interactive, normal)); + fairness.reset(); + normal.add("n1"); + assertEquals("i5", fairness.poll(interactive, normal)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/terrain/TerrainMissingPriorityTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/TerrainMissingPriorityTest.java new file mode 100644 index 00000000..a1775409 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/TerrainMissingPriorityTest.java @@ -0,0 +1,24 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class TerrainMissingPriorityTest { + @Test + void dirtyPromotionAndResetAreExplicit() { + TerrainMissingPriority priority = new TerrainMissingPriority(); + priority.mark(11L, false); + assertFalse(priority.contains(11L)); + + priority.mark(11L, true); + assertTrue(priority.contains(11L)); + priority.remove(11L); + assertFalse(priority.contains(11L)); + + priority.mark(12L, true); + priority.clear(); + assertFalse(priority.contains(12L)); + } +}