Speed up Dataloader using the new Torchvision Transforms support for Tensor, batch computation, GPU

username: a6m6m
cooked: <p>Hello there,</p>
<p>According to the following torchvision <a href="https://github.com/pytorch/vision/releases/tag/v0.8.0" rel="noopener nofollow ugc">release</a> transformations can be applied on tensors and batch tensors directly. It says:</p>
<blockquote>
<p>torchvision transforms are now inherited from nn.Module and can be torchscripted and applied on torch Tensor inputs as well as on PIL images. They also support Tensors with batch dimension and work seamlessly on CPU/GPU devices</p>
</blockquote>
<p>Here a snippet:</p>
<pre><code class="lang-auto">import torch
import torchvision.transforms as T

transforms = torch.nn.Sequential(
    T.RandomCrop(224),
    T.RandomHorizontalFlip(p=0.3),
    T.ConvertImageDtype(torch.float),
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
)

tensor_image = torch.randint(0, 256, size=(3, 256, 256), dtype=torch.uint8)

# works directly on Tensors
out_image1 = transforms(tensor_image)

# on the GPU
out_image1_cuda = transforms(tensor_image.cuda())

# with batches
batched_image = torch.randint(0, 256, size=(4, 3, 256, 256), dtype=torch.uint8)
out_image_batched = transforms(batched_image)

</code></pre>
<p>How can we utilize this fact to improve our dataloaders performance.</p>
<p>Loading and Transforming ImageNet images is taking on my computer approx 5s to load a 256-images batch. Given the fact that I will move the input tensors to device anyway why not apply transformations after doing this step?</p>
<p>The typical work flow is to define a transformation composition and set it up for a torch dataset. The composition transforms PIL images to tensors in the final step as following:</p>
<pre><code class="lang-auto">import torchvision.transforms as T
transform = T.compose([
                     T.Resize(...),
                     T.Crop(....),
                     T.Flip(...),
                      # And finally
                     T.ToTensor(),
])
</code></pre>
<p>This doesn’t make use of the fact that transformations can be applied on device tensors directly.</p>
<p>My thought is to add transformation statement inside the data loading loop as following:</p>
<pre><code class="lang-auto">for i,(input,target) in enumerate(dataloader):
      # move data to GPU
      input = input.to(device)
      target = target.to(device)
      # APPLY transformations
      input = transform(input)
      # feed to model
      output = model(input)     
</code></pre>
<p>However this approach is not clean and seamless as simply setting a transformation to a dataset. Though I am afraid it is the only possible approach.</p>
<p>I am not sure if this approach will even speed up loading. Will it?</p>
<p>Any thoughts?</p>

username: marcelodiaz
cooked: <p>Hi! Have you figured this out? I’m also interested on improving computation times for transformations.</p>

username: a6m6m
cooked: <p>Hello! I actually went on and did an experiment on the STL10 dataset. The approach I mentioned here which is to apply transformation inside the training loop works well and speeds transformation (I didn’t manage to find a cleaner way. However, after using it I found it maintainable). You can also set pin_memory=True in data loaders to speed up the process of moving data into the GPU. Finally, if the transformation is not random and fixed you can cache it and not apply it every epoch.</p>

username: brianbt
cooked: <p>Hi! According to <a href="https://pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html" rel="noopener nofollow ugc">Cifar10 Example</a>, the dataset and dataloader pipeline is:</p>
<pre><code class="lang-auto">transform = transforms.Compose(
    [transforms.RandomCrop(224),
     transforms.RandomHorizontalFlip(p=0.3)]
)

batch_size = 4

trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
                                        download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size,
                                          shuffle=True, num_workers=2)
</code></pre>
<p>The transform process is done inside <code>torchvision.datasets.CIFAR10</code>, so one image will have one transforms() call, and later dataloader will concat them.</p>
<p>Do you think it is a good practice to do</p>
<pre><code class="lang-auto"># pass this transform function to torchvision.datasets.CIFAR10, instead of transform.Compose()
transform = torch.nn.Sequential(
    transforms.RandomCrop(224),
    transforms.RandomHorizontalFlip(p=0.3),
).to('cuda')

# edit inside torchvision.datasets.CIFAR10
def __getitem__(self, index: int) -&gt; Tuple[Any, Any]:
        ...
        if self.transform is not None:
            img = self.transform(transforms.functional.to_tensor(img))
            
        ...
# Run our code as normal
</code></pre>

username: the-dharma-bum
cooked: <p>Hi,</p>
<p>One way i can think of is to perform the transformation in the collate step of the DataLoader. A custom collate function can be defined to perform the batching, the cpu-&gt;gpu transfer, and the transformations.<br>
Something like this:</p>
<pre><code class="lang-python">from __future__ import annotations
from functools import partial
import numpy as np
import torch
from torch.utils.data import DataLoader, default_collate
import torchvision.transforms as T
from torchvision.datasets import CIFAR10


def get_transform() -&gt; callable:
    return torch.nn.Sequential(
        T.RandomCrop(32),
        T.RandomHorizontalFlip(p=0.3),
        T.ConvertImageDtype(torch.float),
        T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    )


def collate_and_transform(
    batch: list[np.ndarray, int],
    device: torch.device,
    transform: callable=None,
    ) -&gt; tuple[torch.Tensor]:
    inputs, targets = default_collate(batch)
    inputs = inputs.to(device)
    if transform is not None:
        inputs = transform(inputs)
    return inputs, targets


class NumpyCIFAR10(CIFAR10):

    def __getitem__(self, index: int) -&gt; tuple[np.ndarray, int]:
        return self.data[index].transpose(2, 0, 1), self.targets[index]

transform = get_transform()
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
collate_kwargs = dict(transform=transform, device=device)
custom_collate = partial(collate_and_transform, **collate_kwargs)

trainset = NumpyCIFAR10(root='/data/Datasets/cifar10', train=True)
trainloader = DataLoader(trainset, collate_fn=custom_collate)

inputs, targets = next(iter(trainloader))
</code></pre>
<p>I’m not sure if it’s actually cleaner, but it does the job without changing the training loop. The new TorchData allows for a much smoother pipeline imo.</p>