-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lazy loading of wells and multi-site plates #66
Comments
To summarize so of today's development: I quickly looked through it now. @mfranzon can we rebase that to the current main of ome-zarr-py? They now have their own implementation that allows reading multiple resolution levels and we don't need to suggest our quick fix there anymore. |
I've been working on how we could save positional information and where in the metadata this would go. Was a bit confusing to figure out, but I think I found a good answer now that fits in the spec and fits with the recommendations we got in the ome-zarr issue here. I will try to visually explain it, so that it will be easier for others to understand than it was for me :) The metadata is saved in the .zattrs file on the field of view level, thus each field can get metadata about its positioning (which should give us good flexibility with regard to positioning of fovs). Here is where this goes in the file hierarchy:
The metadata can contain coordinateTransformations metadata. They contain fields of type We add the coordinateTransformations translation metadata at the dataset level, i.e. per pyramid level. An example .zattrs file can look like this:
A few notes:
|
The napari-ome-zarr plugin (through the ome-zarr-py library) can already interpret these translation information correctly for single OME-Zarr files (not for wells or plate, just for single FOVs). Thus, that means we are using the values here as we should and as recommended by Will Moore here. I created two example datasets with this metadata hardcoded, both are variants of the single site, 2x2 UZH dataset. I loaded them site by site, thus each site gets added as a separate channel (much longer channel list, would not scale for full plates at all, but good for testing). In the first dataset, In the second dataset, These two test sets should give us a good opportunity to test our lazy-loading of Wells (& eventually Plates) with multiple field of views saved as separate images. Also, if we can follow this logic of placing images into a dask array based on such coordinates, we have an easy road to also process arbitrary patterns of sites, see e.g. https://github.com/fractal-analytics-platform/mwe_fractal/issues/23 Given that this seems to be the good spot to put positional metadata (if we also manage to use it), there are 2 parts remaining then:
I will be tackling steps towards making 1 more general, getting https://github.com/fractal-analytics-platform/mwe_fractal/issues/46 ready for implementation. We can work with the two example OME-Zarr files |
Reading through the spec once more has answered this question:
I will update my two examples to correspond to this specification and check whether things still work that way :) Also kind of useful, given that coordinates we get from the Yokogawa are in physical units :) Also:
Answer:
=> Must be length for for our case, with 1.0 for the channel axis. Will also test that. |
I adapted the two example OME-Zarrs to the spec with correct translations in physical scales and with scale parameters for every pyramid level. It works as expected. The .zattrs file now looks like this:
|
Quick notes, @jluethi
Yes, no problem on this I will test ita little to be sure that the new ome-zarr-py version works.
I got the point, my bad, probably during our discussion yesterday I misunderstood what you mean when you talking about 'Well'. I thought that we wanted to modify the Plate class but at Well level, so introducing the sites handling well per well without taking care of the Plate level. This should have ended up in a situation in which, each Well in a Plate had correct number and position of sites but not the position of the Wells in a Plate.
True, I tried a grid approach as quick implementation, I hoped to have it working in a couple of hours, instead it requires me a little bit of effort. This time no misunderstanding, we agreed on the coordinate-based :)! I wanted to implement a grid base, quick and dirty, just to have the feeling of the visualization and check if we got some improvements in napari or if we have to change something also from the plugin point of view. |
Exactly. This approach seems reasonable to me and is also what Will Moore suggested in the ome-zarr issue here
While the thing that is bonus here clearly is part of our scope. But having the
Sounds good :) If it's easy-ish to implement, that a good default. If you notice that you need the physical coordinates again, we can also work with them directly instead of having to compute them based on grid parameters :) e.g. hard code the shift parameters for the 2x2 case first, and then we already know where we'll get this information in the metadata afterwards |
We are testing a custom version of reader.py, where each FOV is assigned to a single Node() instance (in the Well class). Pros: it automatically reads metadata, nice! If we use the ones by Joel (replicated for the four FOVs and adjusted to create a grid), we obtain a figure where the 2x2 grid exists even if we completely removed its definition reader.py -- see figure below. Note that we just chose the displacements to produce a 2x2 grid, without checking the correct order.. also note that the "scale" transformation is implemented, and a single FOV is 351x416 in physical units (as we see e.g. by moving the mouse pointer over the image). Cons: channels from different FOVs are not considered independently, which cannot be the way to go. Still working on it. It's possible that the independent-channel issue cannot be solved when working with independent FOVs, and then we'll switch back to other previous ideas. |
To briefly summarize our discussion: |
@tcompa Thinking about this a bit more now and reading a bit of documentation, I think it's unlikely to work to put multiple fovs with different translation parameters in the same layer. In napari's layer logic, each layer has one |
Let's keep two things separate: pyramid loading at the single-well level (be it for one or many FOVs) and lazy loading of multiple FOVs. The pyramid loading within the Well class seems quite simple, and here is a version of ome_zarr's
It is nothing fancy, but it works. When loading a well made of 5x5 sites (all merged into a single FOV), the improved responsiveness is quite clear. |
Some more info about this pyramid-loading well example: Part of the logs read
and indeed when repeatedly zooming in/out I see that napari loads levels 0, 1 and 2. |
I've been testing the well lazy-loading implementation from @tcompa on the 9x8 test case. Here are a few observations:
=> The increased amounts of sites and low-res pyramids distributed over more small files in the multi-FOV cases has a strong overhead performance penalty for initial loading. Even for a single well, it's almost 10x slower. Browsing the image, the difference is less pronounced, but still there. Zooming into the top left site is a bit laggier for the Multi-FOV case. It takes about 11s on my setup to load it at full res. While on the single-FOV setup (when also loaded as a single well), it feels a bit smoother, loads full res of top left side in about 4s => The performance penalty is still there for browsing, but not quite as pronounced anymore. Overall, those performance hits aren't great, but we could probably live with them. The question is how they scale with size of the experiment. I'm currently processing the 23 well test case, let's check whether there's an easy way to generalize the multi-FOV framework to the plate to see performance there. |
Reading through the dask documentation, this section has some very interesting information:
I wonder whether we could use this as well, e.g. depending on the pyramid level, we load multiple small "on-disk" chunks into a larger dask array chunk. Given they recommend doing this here, maybe this would deal with some of the performance overhead? There is additional interesting discussion on choosing good chunk sizes here (and how the dask array chunk size should be larger, in the multiple to 100s of MB (not the kbs we have for tiny pyramid chunks): https://blog.dask.org/2021/11/02/choosing-dask-chunk-sizes |
The simplest approach to change dask chunking didn't help: Maybe we can test this in a simpler dummy case and figure out whether the performance is limited by the size of dask chunks or by IO of many files. |
Here's a quick test for loading a single dask array from either a single-FOV or multi-FOV zarr file. I tried to isolate it as much as possible from ome-zarr's Notes on the test script:
Test script (for a single-well and 9x8-sites case): Click to expand!import time
import dask
import dask.array as da
from ome_zarr.io import ZarrLocation
def get_field(zarr, field_index: int, level: int):
path = f"{field_index}/{level}"
data = zarr.load(path)
return data
lazy_get_field = dask.delayed(get_field)
# Pyramid shapes (coarsening factor = 2)
FOV_shapes = [
(3, 1, 2160, 2560),
(3, 1, 1080, 1280),
(3, 1, 540, 640),
(3, 1, 270, 320),
(3, 1, 135, 160),
]
size = "9x8"
kinds = ["singlefov", "multifov"]
Debug = False
for level in range(4):
for kind in kinds:
print(f"{size}, level={level}, {kind}")
if kind == "singlefov":
rows, columns = [int(x) for x in size.split("x")]
tile_shapes = [(x[0], x[1], x[2] * rows, x[3] * columns) for x in FOV_shapes]
rows, columns = 1, 1
elif kind == "multifov":
rows, columns = [int(x) for x in size.split("x")]
tile_shapes = FOV_shapes[:]
well = f"/home/tommaso/Desktop/20200812-Cardio-1well-{size}-{kind}-mip.zarr/B/03"
zarr = ZarrLocation(well)
t_0 = time.perf_counter()
lazy_rows = []
for row in range(rows):
lazy_row = []
for col in range(columns):
field_index = (rows * col) + row
if Debug:
# Load np arrays, to verify shapes are correct
tile = get_field(zarr, field_index, level)
assert tile.shape == tile_shapes[level]
else:
# Lazily load dask arrays
tile = da.from_delayed(lazy_get_field(zarr, field_index, level), shape=tile_shapes[level], dtype="uint16")
lazy_row.append(tile)
lazy_rows.append(da.concatenate(lazy_row, axis=-1))
x = da.concatenate(lazy_rows, axis=-2)
y = x.rechunk(chunks={2:2160, 3:2560})
z = y.compute()
mean_z = da.mean(z).compute()
t_1 = time.perf_counter()
print(f" Final shape: {y.shape}")
print(f" Final chunks: {y.chunks}")
print(f" Average(array)={mean_z}")
print(f" Elapsed: {t_1 - t_0:.4f} s")
print()
print("-----")
print() The summary of the output is:
This supports our guess that loading the low-res pyramid levels is what kills the multi-FOV visualization performance. As a reference, here is the full output: Click to expand!
|
More high-level comment on those results: this doesn't look good for the multi-FOV route. Let's keep in mind that we expect two lazy features:
I think that in the case of low-resolution levels, the combination of these two features is not working (in the multi-FOV case). The reason should be that when working at low-res (e.g. with the first napari view, before zooming in) we typically have several FOVs all showing, since each one of them is quite small (e.g. a few hundreds of pixels in each one of X and Y directions, or even less if we use a more aggressive coarsening) and we are looking at the entire well (or a large fraction of it). This means that any small movement in this context hits the bottleneck of loading many small files (in the multi-FOV case). The single-FOV case doesn't have this issue, because in that case we only have one or very few files for low-res levels, which can be loaded fast (see results in previous comment). Thus it seems that loading all those low-res or mid-res levels (which we have to do quite often, unless we want to stick with the highest-res level only.. which makes no sense) could be the bottleneck of napari performances for multi-FOV data. |
Concerning a slightly independent feature we need (that is, the pyramid loading within the Well class), I've put my simple solution into a separate fork of ome-zarr-py (tcompa/ome-zarr-py@89237e9) and we can later decide what to do: opening a PR for them or updating fractal-napari-plugins-collection (which I think is currently obsolete). Probably a PR with this isolated change (and no discussion of multi-FOV) is the right way to go? |
Great, very useful to have @tcompa! |
A few thoughts:
But if those chunk sizes remain like this, I don't think multi-fov approaches are feasible for something we want to be able to browse on a plate level... Code here!
|
I am a bit confused by this remark, could you please clarify? (or let's discuss it live, if it's easier) You say that a chunk at level 4 takes 26 kB on disk and has 3 MB of data, while the same chunk at level 4 also takes 22 kB and contains 42 kB of data. What changed between the two cases? And which level-4 chunks reach 623 kB? Concerning the "848 B vs 22 kB", this seems a reasonable ratio when changing the coarsening factor from 2 to 3:
In any case: it's possible that we have something wrong in how we save things, let's check it. |
Quick check on my side. I look at a single well with 9x8 sites, and I build 4 pyramid levels (0, 1, 2, 3) with XY coarsening factor 2. These are the file sizes on disk for each chunk:
And here is the total number of files per level, in the two cases:
At the moment I don't see anything unexpected in these numbers (the fact that multi-FOV scheme produces a lot of small files is suboptimal but consistent). |
@tcompa Great overview. What is the total size per layer for multi-FOV? I think single FOV sizes can vary quite a bit, would be interesting if the total is ~10% higher, 2x higher or 10x higher... |
Total on-disk size per level:
And for this example FOVs are quite homogeneous, with folders fluctuating from 22 to 25 M (counting all levels at the same time). |
WARNING: All my tests in this issue are on MIP-projected zarr files, but the size ratios seem to be similar in the original files. |
PR on ome-zar-py, with pyramid loading for Well: ome/ome-zarr-py#209 |
With the PR above, we can close this issue. It's possible to lazy-load multi-FOV wells now, but this approach does not scale to plates (see here: https://github.com/fractal-analytics-platform/mwe_fractal/issues/74) |
This should be retested, after #92. |
I confirm that recent tests of timing on my side (#66 (comment)) were performed on MIP zarr files, which (somewhat by accident?) had the correct dtypes (uint16) at all pyramid levels. So #92 does not apply to those numbers. I still have to verify whether file sizes were checked for data with the correct/wrong dtype. |
First attempt towards pelkmanslab parsl/slurm config - ref #34
[not a fractal workflow issue, but we should have a high-level issue to discuss our progress on this]
We want to be able to lazily load single wells, not just whole plates. And we also want to support plates where each well consists of more than a single site (currently all fused into one, see #36).
This is work required on the ome-zarr-py side to allow to load images accordingly. Also, while doing this loading, we'll need to find a place to store well dimensions (e.g. are the sites arranged as 9 by 8 or 8 by 9) somewhere in the OME-Zarr file and read it. See this issue for guidance of how we can implement this: ome/ome-zarr-py#200
The text was updated successfully, but these errors were encountered: