diff --git a/src/chunk_manager/backend.ts b/src/chunk_manager/backend.ts index df56bb02bb..004fbf635d 100644 --- a/src/chunk_manager/backend.ts +++ b/src/chunk_manager/backend.ts @@ -30,6 +30,7 @@ import { ChunkState, getChunkDownloadStatisticIndex, getChunkStateStatisticIndex, + MemoryLimitFlags, numChunkMemoryStatistics, numChunkStatistics, REQUEST_CHUNK_STATISTICS_RPC_ID, @@ -727,6 +728,13 @@ export class ChunkQueueManager extends SharedObjectCounterpart { gpuMemoryChanged = new NullarySignal(); + /** + * Bit mask of `MemoryLimitFlags` indicating which memory limits are currently + * preventing visible chunks from being loaded. Written at the end of each + * `process()` pass and observed by the frontend to show a status message. + */ + memoryLimitReached: SharedWatchableValue; + private numQueued = 0; private numFailed = 0; private gpuMemoryGeneration = 0; @@ -751,6 +759,7 @@ export class ChunkQueueManager extends SharedObjectCounterpart { getCapacity(options.downloadCapacity), ]; this.computeCapacity = getCapacity(options.computeCapacity); + this.memoryLimitReached = rpc.get(options.memoryLimitReached); } scheduleUpdate() { @@ -912,7 +921,11 @@ export class ChunkQueueManager extends SharedObjectCounterpart { this.addChunkToQueues_(chunk); } - private processGPUPromotions_() { + /** + * @returns true if a `VISIBLE`-tier chunk could not be promoted to GPU memory + * because the GPU memory limit is full of equal-or-higher-priority chunks. + */ + private processGPUPromotions_(): boolean { const queueManager = this; function evictFromGPUMemory(chunk: Chunk) { queueManager.freeChunkGPUMemory(chunk); @@ -941,11 +954,14 @@ export class ChunkQueueManager extends SharedObjectCounterpart { evictFromGPUMemory, ) ) { - break; + // If the blocked chunk is currently visible, the GPU memory limit is + // preventing data the user is looking at from being displayed. + return priorityTier === ChunkPriorityTier.VISIBLE; } this.copyChunkToGPU(promotionCandidate); this.updateChunkState(promotionCandidate, ChunkState.GPU_MEMORY); } + return false; } freeChunkGPUMemory(chunk: Chunk) { @@ -1003,7 +1019,14 @@ export class ChunkQueueManager extends SharedObjectCounterpart { rpc.invoke("Chunk.update", msg, transfers); } - private processQueuePromotions_() { + /** + * @returns true if a `VISIBLE`-tier chunk could not be downloaded because the + * system memory limit is full of equal-or-higher-priority chunks. The + * per-source download/compute concurrency limits are request-rate limits + * rather than memory limits and are intentionally not reported here. + */ + private processQueuePromotions_(): boolean { + let systemMemoryBlocked = false; const evict = (chunk: Chunk) => { switch (chunk.state) { case ChunkState.DOWNLOADING: @@ -1061,6 +1084,9 @@ export class ChunkQueueManager extends SharedObjectCounterpart { evict, ) ) { + if (priorityTier === ChunkPriorityTier.VISIBLE) { + systemMemoryBlocked = true; + } return; } this.updateChunkState(promotionCandidate, ChunkState.DOWNLOADING); @@ -1084,6 +1110,7 @@ export class ChunkQueueManager extends SharedObjectCounterpart { this.computeEvictionQueue.candidates(), this.computeCapacity, ); + return systemMemoryBlocked; } process() { @@ -1092,9 +1119,17 @@ export class ChunkQueueManager extends SharedObjectCounterpart { } this.updatePending = null; const gpuMemoryGeneration = this.gpuMemoryGeneration; - this.processGPUPromotions_(); - this.processQueuePromotions_(); + const gpuMemoryBlocked = this.processGPUPromotions_(); + const systemMemoryBlocked = this.processQueuePromotions_(); this.logStatistics(); + let memoryLimitFlags = MemoryLimitFlags.NONE; + if (gpuMemoryBlocked) { + memoryLimitFlags |= MemoryLimitFlags.GPU; + } + if (systemMemoryBlocked) { + memoryLimitFlags |= MemoryLimitFlags.SYSTEM; + } + this.memoryLimitReached.value = memoryLimitFlags; if (this.gpuMemoryGeneration !== gpuMemoryGeneration) { this.gpuMemoryChanged.dispatch(); } diff --git a/src/chunk_manager/base.ts b/src/chunk_manager/base.ts index d727085373..fd80b0801a 100644 --- a/src/chunk_manager/base.ts +++ b/src/chunk_manager/base.ts @@ -73,6 +73,17 @@ export enum ChunkMemoryStatistics { export const numChunkMemoryStatistics = 3; +/** + * Bit flags indicating which memory limits are currently blocking visible + * (`ChunkPriorityTier.VISIBLE`) chunks from being loaded. Shared from the + * backend to the frontend so the UI can surface a status message. + */ +export enum MemoryLimitFlags { + NONE = 0, + GPU = 1, + SYSTEM = 2, +} + export const numChunkDownloadStatistics = 2; export const numChunkStatistics = diff --git a/src/chunk_manager/frontend.ts b/src/chunk_manager/frontend.ts index 3df76ce075..b37105b4d8 100644 --- a/src/chunk_manager/frontend.ts +++ b/src/chunk_manager/frontend.ts @@ -24,6 +24,7 @@ import { CHUNK_QUEUE_MANAGER_RPC_ID, CHUNK_SOURCE_INVALIDATE_RPC_ID, ChunkState, + MemoryLimitFlags, REQUEST_CHUNK_STATISTICS_RPC_ID, } from "#src/chunk_manager/base.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; @@ -112,6 +113,12 @@ export class ChunkQueueManager extends SharedObject { enablePrefetch = new TrackableBoolean(true, true); + /** + * Bit mask of `MemoryLimitFlags` set by the backend indicating which memory + * limits are currently preventing visible chunks from being loaded. + */ + memoryLimitReached: SharedWatchableValue; + constructor( rpc: RPC, public gl: GL, @@ -125,6 +132,10 @@ export class ChunkQueueManager extends SharedObject { ) { super(); + this.memoryLimitReached = this.registerDisposer( + SharedWatchableValue.make(rpc, MemoryLimitFlags.NONE), + ); + const makeCapacityCounterparts = (capacity: CapacitySpecification) => { return { itemLimit: this.registerDisposer( @@ -144,6 +155,7 @@ export class ChunkQueueManager extends SharedObject { enablePrefetch: this.registerDisposer( SharedWatchableValue.makeFromExisting(rpc, this.enablePrefetch), ).rpcId, + memoryLimitReached: this.memoryLimitReached.rpcId, }); } diff --git a/src/ui/memory_limit_status.ts b/src/ui/memory_limit_status.ts new file mode 100644 index 0000000000..a40f68e0b7 --- /dev/null +++ b/src/ui/memory_limit_status.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Shows a dismissable status message when Neuroglancer cannot display + * visible data because it has reached its GPU or system memory limit. + */ + +import { MemoryLimitFlags } from "#src/chunk_manager/base.js"; +import type { ChunkQueueManager } from "#src/chunk_manager/frontend.js"; +import { StatusMessage } from "#src/status.js"; +import type { RefCounted } from "#src/util/disposable.js"; + +/** + * How long the memory limit must stay clear before the status message is + * hidden and the dismissed state is reset. Debouncing the falling edge avoids + * flapping the message during ordinary chunk churn (e.g. while panning) and + * ensures the message only reappears after memory pressure has genuinely + * subsided and is hit again. + */ +const CLEAR_DEBOUNCE_MS = 1500; + +function describeMemoryLimit(flags: number): string { + const limits: string[] = []; + if (flags & MemoryLimitFlags.GPU) { + limits.push("GPU"); + } + if (flags & MemoryLimitFlags.SYSTEM) { + limits.push("system"); + } + const which = limits.join(" and "); + return ( + `Some data is not being displayed because Neuroglancer has reached its ` + + `${which} memory limit. You can increase the memory limits in the ` + + `settings panel (gear icon at the top right).` + ); +} + +/** + * Registers a handler that surfaces a status message whenever the memory limit + * blocks visible chunks from loading, and hides it once memory pressure clears. + * + * The message can be dismissed by the user; once dismissed it will not reappear + * until the memory limit has been continuously clear for `CLEAR_DEBOUNCE_MS` + * and is subsequently reached again. + */ +export function registerMemoryLimitStatusMessage( + context: RefCounted, + chunkQueueManager: ChunkQueueManager, +) { + const watchable = chunkQueueManager.memoryLimitReached; + let statusMessage: StatusMessage | undefined; + let shownFlags = MemoryLimitFlags.NONE; + let dismissed = false; + let clearTimer: number | undefined; + + const hideMessage = () => { + if (statusMessage !== undefined) { + statusMessage.dispose(); + statusMessage = undefined; + } + shownFlags = MemoryLimitFlags.NONE; + }; + + const showMessage = (flags: number) => { + if (statusMessage !== undefined && shownFlags === flags) { + return; + } + hideMessage(); + shownFlags = flags; + const message = new StatusMessage(/*delay=*/ false); + message.setPreventFocusChangeOnMouseDown(true); + message.element.textContent = describeMemoryLimit(flags) + " "; + const dismissButton = document.createElement("button"); + dismissButton.textContent = "Dismiss"; + dismissButton.addEventListener("click", () => { + dismissed = true; + hideMessage(); + }); + message.element.appendChild(dismissButton); + statusMessage = message; + }; + + const cancelClearTimer = () => { + if (clearTimer !== undefined) { + window.clearTimeout(clearTimer); + clearTimer = undefined; + } + }; + + const update = () => { + const flags = watchable.value; + if (flags !== MemoryLimitFlags.NONE) { + cancelClearTimer(); + if (!dismissed) { + showMessage(flags); + } + } else if (clearTimer === undefined) { + // Debounce the falling edge: only clear once memory pressure has stayed + // resolved for a while. + clearTimer = window.setTimeout(() => { + clearTimer = undefined; + dismissed = false; + hideMessage(); + }, CLEAR_DEBOUNCE_MS); + } + }; + + context.registerDisposer(watchable.changed.add(update)); + context.registerDisposer(() => { + cancelClearTimer(); + hideMessage(); + }); + update(); +} diff --git a/src/viewer.ts b/src/viewer.ts index a985d0c2c3..b1aec47f2a 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -89,6 +89,7 @@ import { LayerListPanelState, } from "#src/ui/layer_list_panel.js"; import { LayerSidePanelManager } from "#src/ui/layer_side_panel.js"; +import { registerMemoryLimitStatusMessage } from "#src/ui/memory_limit_status.js"; import { setupPositionDropHandlers } from "#src/ui/position_drag_and_drop.js"; import { ScreenshotDialog } from "#src/ui/screenshot_menu.js"; import { SelectionDetailsPanel } from "#src/ui/selection_details.js"; @@ -675,6 +676,8 @@ export class Viewer extends RefCounted implements ViewerState { }), ); + registerMemoryLimitStatusMessage(this, this.dataContext.chunkQueueManager); + this.makeUI(); this.updateShowBorders();