Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
improve data loader performance by giovp · Pull Request #565 · scverse/spatialdata · GitHub
Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' improve data loader performance by giovp · Pull Request #565 · scverse/spatialdata · GitHub
Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' improve data loader performance by giovp · Pull Request #565 · scverse/spatialdata · GitHub
Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' improve data loader performance by giovp · Pull Request #565 · scverse/spatialdata · GitHub
Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' improve data loader performance by giovp · Pull Request #565 · scverse/spatialdata · GitHub
Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); improve data loader performance by giovp · Pull Request #565 · scverse/spatialdata · GitHub
Skip to content

improve data loader performance - #565

Closed
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader
Closed

improve data loader performance#565
giovp wants to merge 4 commits into
mainfrom
giovp/dataloader

Conversation

@giovp

@giovpgiovp commented May 24, 2024

Copy link
Copy Markdown
Member

so I've been wanting to take another look at this for a long time, I used https://github.com/benfred/py-spy with speedscope format, you can see screenshot below.
image

I've been doing this on the xenium_rep_1 dataset from the paper, and been using the following code (adapting from @LucaMarconato code ):

Details
importjsonimportnumpyasnpimportpandasaspdimporttorchvision.transforms.v2asTfromspatialdata.dataloader.datasetsimportImageTilesDatasetfromspatialdata.transformationsimportScale, get_transformationfromspatialdata.transformationsimportSequenceasSequenceTransformationfromtorch.utils.dataimportDataLoaderfromtqdmimporttqdmfrompathlibimportPathimportspatialdataassdxeniumrep1=Path(
"/path/to/xenium_rep1_data_aligned.zarr"
)
sdata1=sd.read_zarr(xeniumrep1)
visium=Path(
"/path/to/visium_data_aligned.zarr"
)
sdata3=sd.read_zarr(visium)
TILE_SCALE=10.0REGION="xeniumrep1"sdata=sdata1sdata.images["hne"] =sdata3.images["CytAssist_FFPE_Human_Breast_Cancer_full_image"]
defget_ds(sdata: sd.SpatialData):
img_size=224transform_tv=T.Compose(
[
T.ToImage(),
T.Resize((img_size, img_size), antialias=True, interpolation=T.InterpolationMode.BICUBIC),
T.ToTensor(),
]
)
deftransform(output):
image, anno=outputinstance_id, celltype=anno[:, 0].squeeze(), anno[:, 1].squeeze()
image=transform_tv(image.data.transpose(1, 2, 0).compute(scheduler="single-threaded"))
out= {"img": image, "instance_id": instance_id.tolist(), "celltype": celltype.tolist()}
returnoutmu=sdata.shapes["cell_circles"]["radius"].mean()
std=sdata.shapes["cell_circles"]["radius"].std()
# large radius to cover most of the cellslarge_radius=mu+2*stdneighbors_contex=large_radiussdata.shapes["cell_circles"]["radius"] =neighbors_contexinstance_key=sdata.tables["table"].uns["spatialdata_attrs"]["instance_key"]
ds=ImageTilesDataset(
sdata=sdata,
regions_to_images={"cell_circles": "hne"},
regions_to_coordinate_systems={"cell_circles": "aligned"},
return_annotations=[instance_key, "celltype_major"],
tile_scale=TILE_SCALE,
transform=transform,
table_name="table",
)
returndsds=get_ds(sdata)
dl=DataLoader(
ds,
batch_size=256,
num_workers=0,
shuffle=False,
)

this made me realize that, if we want to return the array, than there is an unnecessary step of instantiating the SpatialImage|MultiscaleSpatialImage that is not necessary, and the dask array could be simply returned. This halved the fetch step (across 6 iterations) from ~43s to ~23s total, see below
image

I think the fetch step is what ultimately we want to improve, as it's the one that stream the tiles from the zarr array to the GPU. Now the two main blocks are the transform call and the compute call. The transform call is visualized under compute but it's effectively the wrapper call, where all the DataArray.isel happen, which is where the crops are defined, transformed and set, before the computation is actually triggered with compute.
image
I wonder what could be the next step here to chase performance gain: I think one option would be to basically "prepare" the transformation before on the full array, and then trigger it only at the tile creation in the compute (whereas now, transformation and tile creation is done jointly for each tile). This I think would require significant refactoring though so I wonder if it makes sense at all, and if anyone has other ideas to explore @scverse/spatialdata

@codecov

codecovBot commented May 24, 2024

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 71.42857% with 2 lines in your changes are missing coverage. Please review.

Project coverage is 92.52%. Comparing base (8d902d4) to head (7adc03f).
Report is 8 commits behind head on main.

Current head 7adc03f differs from pull request most recent head 7feb03b

Please upload reports for the commit 7feb03b to get more accurate results.

Additional details and impacted files
@@ Coverage Diff @@## main #565 +/- ##
==========================================
- Coverage 92.53% 92.52% -0.02% 
==========================================
Files 43 42 -1 Lines 6003 6008 +5 ==========================================
+ Hits 5555 5559 +4 - Misses 448 449 +1 
FilesCoverage Δ
src/spatialdata/dataloader/datasets.py90.73% <100.00%> (+0.04%)⬆️
src/spatialdata/_core/query/spatial_query.py94.67% <50.00%> (-0.51%)⬇️

... and 6 files with indirect coverage changes

@LucaMarconato

Copy link
Copy Markdown
Member

Super cool analysis! I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

If most of the time is spent outside dask_image.ndinterp.affine_transform() (the core function used in transform()), then I think that preparing everything before and calling affine_transform() at the end would be a good approach.

But my bet (I need to check by running the profiler), is that the problem is that we load multiple times the same chunks. I think that maybe using .persist() to automatically cache some Dask chunks, and to order the cells so that we randomize the chunks first, and then the cells inside a chunk, would lead to performance improvements.

This second approach has the advantage that it involves only the dataloader class and does not require changes in the transformation code.

@LucaMarconato

Copy link
Copy Markdown
Member

I reviewed the code, looks good to me. We could merge this already or explore first the .persist() approach above in this PR.

system; this back-transforms the target tile into the pixel coordinates. If the back-transformed tile is not
aligned with the pixel grid, the returned tile will correspond to the bounding box of the back-transformed tile
(so that the returned tile is axis-aligned to the pixel grid).
return_genes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Two comments:

  1. I would specify that the layers are AnnData layers and the default layer is X.
  2. I would also allow to pass just a list instead of a dict, that would be interpreted as {'X': genes_list}

@giovp

Copy link
Copy Markdown
MemberAuthor

I'll also try it out (which commands did you use to open py-spy? Or did you set it up to be integrated with your IDE?)

I've just changed the format in py-spy
py-spy record --format speedscope -o profile.speedscope.json -- python process_xenium.py

this was just a push to get the code in another machine. But let me explain what's next.

I've realized that the calculation of the transformed bounding box in the implicit coordinate system takes a fair amount of time and it could in fact be done only in the same way the tile coords dataset is built. I will therefore:

  • move out the transformation from the bounding box query and do it only once at init.
  • Enable to return gexp data from different layers.

The dataset will have only type of output which will be dictionary of the following

{
"tile":tile,
"annotations":listofannotations,
"gexp": listofgexp,
}

wdyt?

What I won't do here but would be useful to work on next is:

@LucaMarconato

Copy link
Copy Markdown
Member

Thanks for the explanation. Yes, I think that operating on the transformation at the preprocessing stage is a good approach to improve performance. Also, the option to specify the layer will be useful.

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

@giovp

Copy link
Copy Markdown
MemberAuthor

Regarding the return type, would you remove the SpatialData return type or still leave it as an option?

that's a good question, I would potentially leave it but then technically the dataloader would fail as the default collate_fn only accepts array/mapping[str, array]/list[array], wdyt?

@LucaMarconato

Copy link
Copy Markdown
Member

Ok, then I would probably move the default away from returning SpatialData (but still leave it as an option to the users). I think a good default would be one compatible with the default collate_fn.

@giovpgiovp mentioned this pull request Jul 8, 2024
@giovpgiovp mentioned this pull request Aug 21, 2024
@giovp

giovp commented Sep 3, 2024

Copy link
Copy Markdown
MemberAuthor

close in favour of #687

@giovpgiovp closed this Sep 3, 2024
@giovp
giovp deleted the giovp/dataloader branch September 3, 2024 18:08
Sign up for freeto 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

@giovp@LucaMarconato