GERONIMOBLOG

VLM Inference on multiple GPUs

Parallel Image Captioning with SmolVLM2, Moondream2, Qwen2.5-VL and DDP

Got millions of images without (proper) labels? This article provides guidance on how to caption large image datasets using the latest Vision Language Models (VLMs) efficiently on a single or multiple GPUs and what performance to expect.

Random selection of ImageNet samples
Random selection of ImageNet samples

I recently started training a diffusion model from scratch (blog post soon-ish). As a challenge within a challenge, I found that captioning these huge image datasets can be incredibly time-consuming, particularly when using a single GPU.

In this blog post, I’ll walk you through the process of captioning large image datasets.

I tested three different vision language models (VLMs):

Along the way, I’ll share code snippets, random wisdom, and the performance numbers I observed.

If you are in a hurry and want to see the code right away, head over to my GitHub folder, start with the notebook.

Part 1: One image at a time

The first step in the process is to caption individual images.

Requirements

We need the latest version of transformers and a few other packages to get started.

pip3 install git+https://github.com/huggingface/transformers.git
pip3 install flash-attn num2words torchvision datasets accelerate torchmetrics Pillow==9.4.0

Load dataset: ImageNet

As an example dataset we will explore ImageNet. The ImageNet dataset is a collection of ~1.3 million images. Each image comes with a class label like minibus, space shuttle, Old English Sheepdog, toilet seat, or dog sled (5 random picks out of the 1000 different classes in this dataset).

from datasets import load_dataset

ds = load_dataset("ILSVRC/imagenet-1k", streaming=True, trust_remote_code=True)
First item of ImageNet , a “hand plane” (class 726)
First item of ImageNet , a “hand plane” (class 726)

Load VLM: SmolVLM2

I assume you are working with an NVIDIA GPU but this will also run on Apple silicon if you replace cuda with mps.

import torch
from transformers import AutoProcessor, AutoModelForImageTextToText

device = "cuda"
dtype = torch.bfloat16
repo = "HuggingFaceTB/SmolVLM2-2.2B-Instruct"

processor = AutoProcessor.from_pretrained(repo)
model = AutoModelForImageTextToText.from_pretrained(
    repo, 
    torch_dtype=dtype,
    _attn_implementation="flash_attention_2",
).to(device)

Note: Make sure to load the model with _attn_implementation="flash_attention_2", this speeds things up a lot!

Caption!

VLMs are instruct models, this means we will assemble a conversation with the model where we provide an image and a question. The code simply follows the SmolVLM docs and blog post to implement this.

# Construct a conversation
conversation = [ 
    dict(role="user", 
         content=[
             dict(type="image", image=img),
             dict(type="text", text="Describe the image please"),
         ]) 
]

# Tokenize input
inputs = processor.apply_chat_template(
    conversation,
    add_generation_prompt=True,
    tokenize=True,
    return_dict=True,
    return_tensors="pt",
).to(model.device, dtype=dtype)

# Query model
output_ids = model.generate(**inputs, max_new_tokens=128)

# Remove input tokens from response
output_ids = output_ids[:, inputs["input_ids"].size(1):]

# Decode tokens to text
outputs = processor.batch_decode(output_ids, skip_special_tokens=True)

print(outputs[0].strip())

The image depicts a vintage hand plane, which is a woodworking tool used for shaping wood. The plane is made of metal, specifically aluminum, and has a rectangular shape with a slightly curved body. The blade of the plane is visible and appears to be made of a dark, possibly steel material. The blade is held in place by a metal lever, which is located on the right side of the plane. This lever is used to adjust the depth of the cut.

The plane has a wooden handle, which is attached to the right side of the plane. The handle is dark brown and appears to be made of a material like ….

That’s a good start. Depending on your needs and creativity you would maybe change the prompt. In my case:

Let’s try that again with a refined prompt.

prompt = """The image shows a hand plane. Please come up with a short 
image caption, list and describe the main objects shown in the image. 
Keep the caption short, one sentence only."""

... (same as above) 

A vintage hand plane with a wooden handle and a metal blade, marked with the number 4212.

🚀🚀

Let’s put that into a function and add a loop going through the dataset.

Final code: Iterate and caption

from torchvision import transforms as T

# Let's put the captioning part into a function
def caption_smolvlm(img, prompt):
    conversation = [ 
        dict(role="user", 
             content=[dict(type="image", image=img), dict(type="text", text=prompt)]
        ) 
    ]
    
    inputs = processor.apply_chat_template(
        conversation,
        add_generation_prompt=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
    ).to(model.device, dtype=dtype)
    
    output_ids = model.generate(**inputs, max_new_tokens=128)
    output_ids = output_ids[:, inputs["input_ids"].size(1):]
    outputs = processor.batch_decode(output_ids, skip_special_tokens=True)
    
    return outputs[0].strip()

# Be nice to the small VLM
prompt_template = """The image shows a {class_name}. Please come up 
with a short image caption, list and describe the main objects shown 
in the image. Keep the caption short, one sentence only."""

for d in ds["train"]:
    # 1) Resize image to save VRAM
    # 2) Extract classname. d["label"] is a number (e.g. 23) -> convert to string ('vulture')
    image = T.CenterCrop(256)(T.Resize(256)(d["image"]))
    class_name = in_labels[d["label"]]

    # Insert class name into prompt
    prompt = prompt_template.format(class_name=class_name)

    # Query model
    caption = caption_smolvlm(image, prompt)

Ok, this runs at a speed of 1.3 samples per second on an NVIDIA GeForce RTX 3090. At that speed, processing the entire ImageNet (1.33 million images) would take 12 days.

Note: The exact numbers reported here will vary, even on the same type of GPU. I’ve run the same code on different RunPod instances and saw differences of 10–20% on different RTX3090 pods. Probably due to different machine setups (CPU, PCIe lanes, power caps?).

Let’s make this a little faster.

Part 2: Batch Captioning

To speed up the captioning process, we move on to batch captioning. This involves processing multiple images simultaneously, which should reduce the overall time required.

First, we have to modify caption_smolvlm() to pass multiple conversations to the VLM:

def batch_caption_smolvlm2(images, prompts):
    # Construct a list of conversations, each with one image and prompt
    conversations = [ 
        [dict(role="user", content=[dict(type="image", image=img),dict(type="text", text=prompt)])]
        for img, prompt in zip(images, prompts)
    ]

    # Preprocess inputs
    inputs = processor.apply_chat_template(
        conversations,
        add_generation_prompt=True,
        padding=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
    ).to(model.device, dtype=torch.bfloat16)
    
    # Query model and remove input tokens
    output_ids = model.generate(**inputs, max_new_tokens=128)
    output_ids=[ tok_out[len(tok_in):] for tok_in, tok_out in zip(inputs["input_ids"], output_ids) ]     

    generated_texts = processor.batch_decode(
        output_ids, 
        skip_special_tokens=True
    )
    
    return [t.strip() for t in generated_texts]

Next, we use a DataLoader for assembling the batches and resizing the images using multiple CPU threads.

from torchvision import transforms as T
from torch.utils.data import DataLoader

# A collate function converts a list of items to a batch
def collate_fn(items):
    return [
        [T.Resize(256)(i["image"]) for i in items],
        [in_labels[i["label"]] for i in items],
    ]
    
# DataLoader's job is to prepare and preload batches of data
dataloader = DataLoader(
    ds["train"], 
    shuffle=False, 
    collate_fn=collate_fn, 
    batch_size=16,     
    num_workers=2,      # Use 2 threads for prefetching
    prefetch_factor=10  # Each thread will prefetch 10 samples
)

prompt_template = """The image shows a {class_name}. Please come up 
with a short image caption, list and describe the main objects shown 
in the image. Keep the caption short, one sentence only."""

for images, class_names in tqdm(dataloader):
    # Assemble prompts by inserting the actual class name into the template
    prompts = [
        prompt_template.format(class_name=class_names[i])
        for i in range(len(images))
    ]

    # Prompt model with entire batch of images
    captions = batch_caption_smolvlm2(images, prompts)

This speeds things up by ~30%. On a RTX3090, this captions at a speed of around 1.75 images per second. Processing the entire dataset would still take 9 days. We will not get around using multiple GPUs to do this in parallel.

Part 3: Batch Captioning on Multiple GPUs

Final optimization: Use multiple GPUs in parallel. This involves using the torch.distributed package to start multiple processes and distribute the dataset.

Skip this if you are familiar with torch.distributed

If you are not familiar with torch.distributed, this is what my template for multi GPU processing looks like. In essence, we need a few lines of initializing the inter-process communication and use a DistributedSampler which assigns parts of the dataset to each thread (called rank).

import torch
import torch.distributed as dist
from datasets import Dataset
from torch.utils.data import DataLoader
from torch.utils.data.distributed import DistributedSampler
from tqdm import tqdm

def process(rank, is_master, world_size, batch_size=8):
    print(f"Hello from rank {rank}")

    # Create a dummy dataset
    ds = Dataset.from_list(
        [{"data": f"data {i}"} for i in range(100)]
    )

    # Distributed data loader without shuffling
    def collate_fn(items):
        data_list = [item["data"] for item in items]
        return data_list

    sampler = DistributedSampler(ds, shuffle=False)

    dataloader = DataLoader(
      ds, sampler=sampler, collate_fn=collate_fn, batch_size=batch_size
    )

    # Process your data
    for batch in tqdm(dataloader, desc=f"rank {rank}"):
        # Do something useful
        pass

if __name__ == '__main__':
    dist.init_process_group(backend='nccl')

    seed = 42
    torch.manual_seed(seed)

    world_size = dist.get_world_size()
    rank = dist.get_rank()
    is_master = rank == 0  
    torch.cuda.set_device(rank)

    process(rank, is_master, world_size)

    dist.destroy_process_group()

Run the code using torch.distributed with

torchrun --nproc-per-node 2 script.py

The parameter nproc-per-node specifies the number of GPUs on your system.

Let’s adapt the minimal distributed processing template above and incorporate the batch_caption_smolvlm2 function for batch processing of ImageNet samples:

def process(rank, is_master, world_size):
    # Setup
    device = "cuda"
    dtype = torch.bfloat16
    batch_size = 8

    # Suppress tokenizers warning
    os.environ["TOKENIZERS_PARALLELISM"] = "false"

    # Load ImageNet1k dataset
    ds = load_dataset(
      "ILSVRC/imagenet-1k", trust_remote_code=True
    )["train"]

    # Load ImageNet1k labels
    in_labels = load_imagenet_labels()
        
    # DistributedSampler to split the dataset across GPUs
    sampler = DistributedSampler(ds, shuffle=False)

    # Prepare DataLoader
    def collate_fn(items):
        # 1) resize image to save VRAM
        # 2) extract classname. d["label"] is a number (e.g. 23)  -> string ('vulture')
        return [
            [T.CenterCrop(256)(T.Resize(256)(i["image"])) for i in items],
            [in_labels[i["label"]] for i in items],
        ]

    dataloader = DataLoader(
        ds, 
        sampler=sampler,
        collate_fn=collate_fn, 
        batch_size=batch_size,     # 8 samples per batch, maximum which fits on a 24GB VRAM GPU 
        num_workers=2,    # Use 2 threads for prefetching
        prefetch_factor=10  # Each thread will prefetch 10 samples
    )

    prompt_template = """The image shows a {class_name}. Please come up 
with a short image caption, list and describe the main objects shown 
in the image. Keep the caption short, one sentence only."""

    for images, class_names in tqdm(dataloader, f"GPU{rank}"):
        # insert class name into prompts
        prompts = [
            prompt_template.format(class_name=class_names[i])
            for i in range(len(images))
        ]

        captions = batch_caption_smolvlm2(images, prompts)

if __name__ == '__main__':
    dist.init_process_group(backend='nccl')

    seed = 42
    torch.manual_seed(seed)

    world_size = dist.get_world_size()
    rank = dist.get_rank()
    is_master = rank == 0  
    torch.cuda.set_device(rank)

    process(rank, is_master, world_size)

    dist.destroy_process_group()

I left out the imports and implementation of load_imagenet_labels for readability. Find the complete code of dist_captioning.py here.

As expected, increasing the number of GPUs speeds up the process linearly. For example, processing ImageNet with two RTX3090 GPUs takes around 7 days and costs ~69$ (renting a 2x3090 machine for 44c/hour at RunPod).

Throughput for processing first 200 ImageNet samples with SmolVLM2–2.2B (bfloat16). GPUs: RTX 3090. Batch size 16. Smaller edge of images resized to 256px and center cropped 256px. Fraction of time preprocessing dataset (collate, resize): ~2%.
Throughput for processing first 200 ImageNet samples with SmolVLM2–2.2B (bfloat16). GPUs: RTX 3090. Batch size 16. Smaller edge of images resized to 256px and center cropped 256px. Fraction of time preprocessing dataset (collate, resize): ~2%.

Qwen is the Winner (in terms of speed)

I also tested two other VLMs: Moondream2 and Qwen2.5-VL. Below are the performance numbers for all three VLMs.

One surprising finding was that Moondream doesn’t handle batching, meaning it can only process one image at a time. Despite this unexpected limitation, Moondream is quite fast and keeps up with SmolVLM while Qwen’s VLM outperforms the other two in terms of speed.

Throughput for processing first 200 ImageNet samples with stated VLMs (bfloat16). GPUs: RTX 3090. Batch size 16. Smaller edge of images resized to 256px and center cropped 256px. Fraction of time preprocessing dataset (collate, resize): 2–5%.
Throughput for processing first 200 ImageNet samples with stated VLMs (bfloat16). GPUs: RTX 3090. Batch size 16. Smaller edge of images resized to 256px and center cropped 256px. Fraction of time preprocessing dataset (collate, resize): 2–5%.

To not further bore you with walls of python code in this blog post, please find the functions for sequential and batch captioning for each VLM here.

Summary

Captioning large image datasets is a time-consuming task, but by leveraging batch processing and multiple GPUs, it can be significantly sped up. I hope this blog post got you started if you plan to caption billions and billions of images!

All the code snippets above and more can be found in my GitHub repo “3090 shorts”.