Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions src/chunk_manager/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ChunkState,
getChunkDownloadStatisticIndex,
getChunkStateStatisticIndex,
MemoryLimitFlags,
numChunkMemoryStatistics,
numChunkStatistics,
REQUEST_CHUNK_STATISTICS_RPC_ID,
Expand Down Expand Up @@ -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<number>;

private numQueued = 0;
private numFailed = 0;
private gpuMemoryGeneration = 0;
Expand All @@ -751,6 +759,7 @@ export class ChunkQueueManager extends SharedObjectCounterpart {
getCapacity(options.downloadCapacity),
];
this.computeCapacity = getCapacity(options.computeCapacity);
this.memoryLimitReached = rpc.get(options.memoryLimitReached);
}

scheduleUpdate() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1061,6 +1084,9 @@ export class ChunkQueueManager extends SharedObjectCounterpart {
evict,
)
) {
if (priorityTier === ChunkPriorityTier.VISIBLE) {
systemMemoryBlocked = true;
}
return;
}
this.updateChunkState(promotionCandidate, ChunkState.DOWNLOADING);
Expand All @@ -1084,6 +1110,7 @@ export class ChunkQueueManager extends SharedObjectCounterpart {
this.computeEvictionQueue.candidates(),
this.computeCapacity,
);
return systemMemoryBlocked;
}

process() {
Expand All @@ -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();
}
Expand Down
11 changes: 11 additions & 0 deletions src/chunk_manager/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
12 changes: 12 additions & 0 deletions src/chunk_manager/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<number>;

constructor(
rpc: RPC,
public gl: GL,
Expand All @@ -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(
Expand All @@ -144,6 +155,7 @@ export class ChunkQueueManager extends SharedObject {
enablePrefetch: this.registerDisposer(
SharedWatchableValue.makeFromExisting(rpc, this.enablePrefetch),
).rpcId,
memoryLimitReached: this.memoryLimitReached.rpcId,
});
}

Expand Down
128 changes: 128 additions & 0 deletions src/ui/memory_limit_status.ts
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 3 additions & 0 deletions src/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -675,6 +676,8 @@ export class Viewer extends RefCounted implements ViewerState {
}),
);

registerMemoryLimitStatusMessage(this, this.dataContext.chunkQueueManager);

this.makeUI();
this.updateShowBorders();

Expand Down
Loading