Skip to content

Lightsheet and gene-pyramid viewers: one dask layer per level; store fetched tiles without scanning DID's cache index - #341

Merged
stevevanhooser merged 5 commits into
mainfrom
claude/upbeat-dijkstra-bnn303
Oct 23, 2026
Merged

stevevanhooser merged 5 commits into
mainfrom
claude/upbeat-dijkstra-bnn303

Conversation

@stevevanhooser

@stevevanhooser stevevanhooser commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

In the lightsheet viewer, tiles drew slowly in two ways:

  1. Tiles already on disk. The status showed "Loading view 50/112" with nothing in flight. Both viewers built each pyramid level from one da.from_delayed array per chunk or tile, joined with da.block. That gives the graph one layer per chunk, and dask walks every layer each time napari computes a slice. So the cost grew with the size of the whole level, not with the tiles on screen.
  2. Downloaded tiles. The timing log added here showed each new tile taking 30–85 s in total, against 0.2–7 s of transfer. The rest was spent adding the tile to DID's file cache:
    • With tens of thousands of cached files and dozens of download threads finishing together, each is_file / add_file scanned the cache index row by row.
    • DID-python's lock either let a second thread through unguarded or made it sleep a whole second per retry.
    • A lock left behind by a killed viewer blocked every later add for an hour (Python's expiry), 30 s per tile.

Benchmark for (1): a view of 16 chunks, all on local disk, chunks 64×256×256 uint16.

Chunks in level Before After
256 0.05 s 0.02 s
16,384 1.4 s 0.02 s
131,072 26 s 0.02 s

Reading the 16 files directly takes about 15 ms. Building a 131k-chunk level at open time also dropped from about 38 s to 2 s.

Changes

  • ndi.pyramid.multiscale: _build_block_grid is replaced by _build_level_array. Each level is now a single da.map_blocks layer, and each block works out its chunk from block_info. The stored short-circuit for sparse chunks, edge-block shapes and the upsample-fallback reader all work as before.
  • Tile timing log: after each burst of reads, the viewer prints one [lightsheet] tiles: line to stderr. It splits tile time into path lookup (memo hit or the fetcher queue, plus how many of those the workers found on disk vs. downloaded), disk read and decode, and gives the burst's wall-clock span. NDI_LIGHTSHEET_TIMING=0 turns it off. With NDI_LIGHTSHEET_DEBUG, the per-fetch line also reports cache add Xs.
  • Storing fetched tiles (_ChunkFetcher._keepFetched): checks the disk (one stat) instead of the cache index, and lets one fetcher thread at a time add to DID's cache. The underlying cache fixes are in DID itself, FileCache: thread-safe lock, binary-search lookups, cache lock handed on quickly and expiring like MATLAB's VH-Lab/DID-python#89 and fileCache: binary-search the index; the cache lock is handed on quickly VH-Lab/DID-matlab#221, both merged.
  • Download connection pool (ndi.cloud.api.files._download_session): pools 64 connections per host, one per possible download thread. requests' default of 10 made every download past the tenth open and discard its own connection ("Connection pool is full, discarding connection").
  • ndi.gui.app.genepyramid.multiscale.levelArrays: the same one-layer-per-level fix. Levels are still cropped to their true size.
  • CHANGELOG entry under Unreleased → Fixed.

Testing

  • Tests touching the pyramid, both viewers, cloud downloads, bulk upload and ctest pass after merging main: 909 passed, 196 skipped. The gene-pyramid tests include test_a_computed_block_matches_readViewport and test_only_the_touched_tiles_are_fetched.
  • New tests:
    • 16 threads adding fetched members to one real FileCache all land without touching is_file. This passes against DID-python both before and after Navigator: statusIcon, progressPane, sessionInfo #89, and fails without this change.
    • A member that's already cached isn't added twice.
    • The download session pools at least MAX_WORKERS connections.
  • Checked by hand that a ragged, sparse level gives the right values and edge shapes. ruff and black are clean.
  • CI on 1dde67f, after merging main with Bulk document upload with ingestion wait (NDI-matlab#1047); ctest failure values (NDI-matlab#1046) #340: everything passes, including bridge completeness and all four cloud jobs (the pyramid integration tests now read Bulk document upload with ingestion wait (NDI-matlab#1047); ctest failure values (NDI-matlab#1046) #340's rebuilt dataset). At the time of writing only the two Windows smoke tests were still running.
  • Run in napari on a real dataset on an M4 Mac mini: views of cached tiles now fill in without the pause, and new tiles arrive at network speed once the stale cache lock is gone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EJ9PUeM7x2vLsg8FcZQfXo

Each pyramid level was one from_delayed array per chunk joined with
da.block, so the graph had one layer per chunk and dask walked all of
them on every slice. Showing 16 cached chunks cost ~1.4 s on a
16k-chunk level and ~26 s on a 131k-chunk level, against ~15 ms to read
the files. A level is now a single map_blocks layer; the same view takes
~20 ms at any level size.

Also print one [lightsheet] tiles: line per burst of reads, splitting
tile time into path lookup (memo vs fetcher queue), disk read, decode
and the burst span. NDI_LIGHTSHEET_TIMING=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJ9PUeM7x2vLsg8FcZQfXo
levelArrays joined one from_delayed array per tile with da.block, so
dask walked one graph layer per tile on every slice and drawing a view
cost time in proportion to the whole level. Same fix as the lightsheet
loader: one map_blocks layer per level, cropped to the true size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJ9PUeM7x2vLsg8FcZQfXo

Copy link
Copy Markdown
Contributor Author

Two checks are red on this PR, and neither comes from this change:

Every other check that has finished is green, including test (3.10/3.11/3.12) and lint.


Generated by Claude Code

…ndex

A fetched tile took about a minute to be added to DID's file cache once
it held tens of thousands of files and many download threads finished
together: _keepFetched asked the index (is_file, a row-by-row scan), and
DID-python's lock let threads through unguarded or made them sleep a
second per retry. Check the disk instead, and let one fetcher thread at
a time add to the cache. The debug fetch line now reports the add time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJ9PUeM7x2vLsg8FcZQfXo
@stevevanhooser stevevanhooser changed the title Lightsheet and gene-pyramid viewers: build each level as one dask layer Lightsheet and gene-pyramid viewers: one dask layer per level; store fetched tiles without scanning DID's cache index Oct 23, 2026
The shared download Session kept requests' default 10 connections per
host while the lightsheet fetcher runs up to 64 downloads at once, so
every download past the tenth opened its own connection and discarded
it afterwards ("Connection pool is full, discarding connection").
Mount an adapter pooling 64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJ9PUeM7x2vLsg8FcZQfXo
@stevevanhooser
stevevanhooser merged commit bf587aa into main Oct 23, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants