Skip to content

Commit 34de1c3

Browse files
committed
Update documentation, add example
1 parent 437ee45 commit 34de1c3

8 files changed

Lines changed: 330 additions & 64 deletions

File tree

docs/content/docs/tutorials/vision_language_rl.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The generator handles both patterns transparently once `generator.vision_languag
8585
Multimodal support in SkyRL is still early. The current VLM path runs on the FSDP backend and supports multi-image, multi-turn rollouts, but several features are not yet ready:
8686

8787
- **Sample packing** — each VLM family handles images and positional embeddings differently, so packing needs per-model support or a different abstraction.
88-
- **Megatron backend** — the VLM path has not been wired through Megatron yet.
88+
- **Megatron backend** — the VLM path has not been wired through Megatron yet, but SFT is supported (see [`run_sft_megatron_vlm.sh`](https://github.com/NovaSky-AI/SkyRL/blob/main/skyrl-train/examples/train/sft/run_sft_megatron_vlm.sh))
8989
- **Context parallelism (CP)** — many VLM tasks are long context, long horizon tasks which get worse as more images are included.
9090
- **Step-wise training** — step-wise training is currently undergoing rapid development in SkyRL, and will eventually support multimodal models.
9191

examples/train/sft/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ bash examples/train/sft/run_sft_megatron.sh
2626

2727
Trains `Qwen/Qwen3-0.6B` on 4 GPUs with Megatron (TP=2, PP=2). Key defaults: max length 512, batch size 4, 10 training steps.
2828

29+
### VLM SFT (Megatron, multi-GPU)
30+
31+
```bash
32+
# One-time: build a small messages-format dataset from HuggingFaceM4/the_cauldron
33+
uv run examples/train/sft/prepare_cauldron_vlm.py --output-dir $HOME/data/cauldron-vlm
34+
35+
bash examples/train/sft/run_sft_megatron_vlm.sh
36+
```
37+
38+
Fine-tunes the vision-language model `Qwen/Qwen3-VL-2B-Instruct` on 4 GPUs with the Megatron backend (pure DP=4 by default). Scale to larger models by overriding `megatron_config.tensor_model_parallel_size` / `pipeline_model_parallel_size`.
39+
40+
VLM SFT mirrors the constraints of the FSDP VLM RL path (3D RoPE + image-token positions tie image tensors to specific sequence positions), so the following are required and enforced:
41+
42+
- `remove_microbatch_padding=false` — no microbatch padding / sequence packing.
43+
- `megatron_config.sequence_parallel_size=1` and `megatron_config.context_parallel_size=1`.
44+
- `train_on_what=last_assistant_message` — VLM tokenization only supports last-assistant training.
45+
46+
Mixed text+image batches are not supported: every sample in a VLM batch must carry image(s). The dataset must use the chat `messages` format with image content encoded as `{"type": "image", "image": <data-uri>}` (see `prepare_cauldron_vlm.py`).
47+
2948
### LoRA (FSDP, single GPU)
3049

3150
```bash
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Convert HuggingFaceM4/the_cauldron to OpenAI messages format for VLM SFT.
2+
3+
the_cauldron ships each row as ``images`` (a list of PIL images) plus
4+
``texts`` (a list of ``{"user", "assistant", "source"}`` turns). ``SFTTrainer``
5+
expects an OpenAI-style ``messages`` column where image content is encoded as
6+
``{"type": "image", "image": <data-uri>}``. This script does that conversion
7+
and writes the result as Parquet so ``load_dataset(<dir>)`` picks it up.
8+
9+
All images for a row are attached to the first user turn (the standard layout
10+
for chat-template VLM tokenization); remaining turns are text-only. The VLM SFT
11+
path trains on the last assistant message only, so multi-turn rows still
12+
contribute their final response as the supervised target.
13+
14+
Usage::
15+
16+
uv run examples/train/sft/prepare_cauldron_vlm.py \\
17+
--output-dir ~/data/cauldron-vlm --config ai2d --num-rows 512
18+
"""
19+
20+
import argparse
21+
import base64
22+
import io
23+
import os
24+
25+
from datasets import load_dataset
26+
from PIL import Image
27+
28+
29+
def _pil_to_data_uri(img: Image.Image) -> str:
30+
if img.mode in ("RGBA", "LA", "P"):
31+
img = img.convert("RGB")
32+
buf = io.BytesIO()
33+
img.save(buf, format="JPEG")
34+
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
35+
return f"data:image/jpeg;base64,{b64}"
36+
37+
38+
def convert_example(example: dict) -> dict:
39+
image_content = [{"type": "image", "image": _pil_to_data_uri(img)} for img in example["images"]]
40+
41+
messages = []
42+
for i, turn in enumerate(example["texts"]):
43+
user_content = list(image_content) if i == 0 else []
44+
user_content.append({"type": "text", "text": turn["user"]})
45+
messages.append({"role": "user", "content": user_content})
46+
messages.append({"role": "assistant", "content": turn["assistant"]})
47+
48+
return {"messages": messages}
49+
50+
51+
def main() -> None:
52+
parser = argparse.ArgumentParser()
53+
parser.add_argument(
54+
"--output-dir",
55+
default=os.path.expanduser("~/data/cauldron-vlm"),
56+
help="Directory to write the converted parquet shard into.",
57+
)
58+
parser.add_argument(
59+
"--config",
60+
default="ai2d",
61+
help="the_cauldron subset/config name (e.g. ai2d, chart2text, vqav2).",
62+
)
63+
parser.add_argument(
64+
"--num-rows",
65+
type=int,
66+
default=None,
67+
help="Cap on number of rows. Use a small number for smoke tests; None for all.",
68+
)
69+
args = parser.parse_args()
70+
71+
output_dir = os.path.abspath(args.output_dir)
72+
parquet_path = os.path.join(output_dir, "train.parquet")
73+
74+
split = "train" if args.num_rows is None else f"train[:{args.num_rows}]"
75+
print(f"[prepare_cauldron_vlm] Loading HuggingFaceM4/the_cauldron config={args.config} split={split} ...")
76+
ds = load_dataset("HuggingFaceM4/the_cauldron", args.config, split=split)
77+
78+
print(f"[prepare_cauldron_vlm] Converting {len(ds)} rows -> OpenAI messages ...")
79+
ds = ds.map(convert_example, remove_columns=ds.column_names, desc="convert")
80+
81+
os.makedirs(output_dir, exist_ok=True)
82+
print(f"[prepare_cauldron_vlm] Writing {parquet_path} ...")
83+
ds.to_parquet(parquet_path)
84+
print(f"[prepare_cauldron_vlm] Done. {len(ds)} rows -> {parquet_path}")
85+
86+
87+
if __name__ == "__main__":
88+
main()
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/bash
2+
set -x
3+
4+
# VLM SFT training with the Megatron backend for Qwen3-VL-2B-Instruct.
5+
#
6+
# Runs supervised fine-tuning of a vision-language model on a small subset of
7+
# HuggingFaceM4/the_cauldron with pure data parallelism (DP=4) on 4 GPUs.
8+
# For larger models, increase TP/PP below (e.g. tensor_model_parallel_size=2,
9+
# pipeline_model_parallel_size=2).
10+
#
11+
# VLM constraints (mirrored from the FSDP VLM path; 3D RoPE + image token
12+
# positions, see skyrl/backends/skyrl_train/workers/model_wrapper.py):
13+
# - remove_microbatch_padding must be false (no microbatch padding / packing)
14+
# - sequence_parallel_size and context_parallel_size must be 1
15+
# - train_on_what must be last_assistant_message
16+
#
17+
# Usage:
18+
# uv run examples/train/sft/prepare_cauldron_vlm.py --output-dir $HOME/data/cauldron-vlm
19+
# bash examples/train/sft/run_sft_megatron_vlm.sh
20+
#
21+
# Example:
22+
# bash examples/train/sft/run_sft_megatron_vlm.sh num_steps=20 batch_size=8
23+
24+
: "${DATA_DIR:="$HOME/data/cauldron-vlm"}"
25+
: "${CKPT_DIR:="$HOME/ckpts/skyrl_sft_megatron_vlm"}"
26+
27+
if [ ! -f "$DATA_DIR/train.parquet" ]; then
28+
echo "=== Generating the_cauldron VLM SFT dataset ==="
29+
uv run examples/train/sft/prepare_cauldron_vlm.py --output-dir "$DATA_DIR"
30+
fi
31+
32+
uv run --isolated --extra megatron \
33+
python -m skyrl.train.main_sft \
34+
strategy=megatron \
35+
model.path=Qwen/Qwen3-VL-2B-Instruct \
36+
dataset_name="$DATA_DIR" \
37+
dataset_split="train" \
38+
messages_key=messages \
39+
max_length=4096 \
40+
num_steps=30 \
41+
batch_size=4 \
42+
micro_train_batch_size_per_gpu=1 \
43+
remove_microbatch_padding=false \
44+
train_on_what=last_assistant_message \
45+
seed=42 \
46+
optimizer_config.lr=2e-6 \
47+
optimizer_config.weight_decay=1e-2 \
48+
optimizer_config.max_grad_norm=1.0 \
49+
optimizer_config.num_warmup_steps=0 \
50+
optimizer_config.scheduler=constant_with_warmup \
51+
placement.num_nodes=1 \
52+
placement.num_gpus_per_node=4 \
53+
megatron_config.tensor_model_parallel_size=1 \
54+
megatron_config.pipeline_model_parallel_size=1 \
55+
megatron_config.context_parallel_size=1 \
56+
logger=wandb \
57+
project_name=skyrl_sft \
58+
run_name=skyrl_sft_megatron_vlm \
59+
ckpt_path="$CKPT_DIR" \
60+
ckpt_interval=20 \
61+
hf_save_interval=40 \
62+
resume_from="$CKPT_DIR/global_step_20" \
63+
"$@"

skyrl/backends/skyrl_train/workers/megatron/megatron_model_wrapper.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,10 @@ def forward_step(batch_iter, model):
140140
attention_mask = batch["attention_mask"].to(bool)
141141
position_ids = batch["position_ids"]
142142

143-
# VLM image tensors (Qwen-style). Only the first pipeline stage runs
144-
# the vision tower; later stages receive hidden states and never see
145-
# these tensors. The TensorList carries one variable-shape tensor per
146-
# sample, concatenated here into the dense layout the model expects.
147143
vlm_inputs = {}
148-
if batch.get("pixel_values") is not None:
144+
if batch.get("pixel_values") is not None and mpu.get_pipeline_model_parallel_rank() == 0:
149145
vlm_inputs["pixel_values"] = torch.cat(batch["pixel_values"].tensors, dim=0)
146+
if batch.get("image_grid_thw") is not None:
150147
vlm_inputs["image_grid_thw"] = torch.cat(batch["image_grid_thw"].tensors, dim=0)
151148

152149
if self.remove_microbatch_padding:
@@ -462,12 +459,10 @@ def forward_step(batch_iter, model):
462459
sub_seq_lengths_field = batch.get("sub_seq_lengths")
463460
sub_seq_lengths = [t.tolist() for t in sub_seq_lengths_field] if sub_seq_lengths_field is not None else None
464461

465-
# VLM image tensors (Qwen-style). See the note in ``forward``: only
466-
# the first pipeline stage consumes these; the TensorList is one
467-
# variable-shape tensor per sample, concatenated into a dense batch.
468462
vlm_inputs = {}
469-
if batch.get("pixel_values") is not None:
463+
if batch.get("pixel_values") is not None and mpu.get_pipeline_model_parallel_rank() == 0:
470464
vlm_inputs["pixel_values"] = torch.cat(batch["pixel_values"].tensors, dim=0)
465+
if batch.get("image_grid_thw") is not None:
471466
vlm_inputs["image_grid_thw"] = torch.cat(batch["image_grid_thw"].tensors, dim=0)
472467

473468
if self.remove_microbatch_padding:

skyrl/backends/skyrl_train/workers/megatron/megatron_worker.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ def _get_module_for_offload(self):
500500
# The underlying offloadable module is `self.actor_module` instead of `self.model`.
501501
return self.actor_module
502502

503+
def _drop_pixel_values_on_non_first_pp_stage(self, data: TrainingInputBatch) -> None:
504+
"""
505+
Drop ``pixel_values`` from the batch on every pipeline stage except the first.
506+
Do this prior to moving to GPU to save memory
507+
"""
508+
if mpu.get_pipeline_model_parallel_rank() != 0 and data.get("pixel_values") is not None:
509+
data["pixel_values"] = None
510+
503511

504512
class MegatronPolicyWorkerBase(MegatronWorker, PolicyWorkerBase):
505513
def __init__(self, **kwargs):
@@ -670,6 +678,7 @@ def forward(
670678
micro_dicts = []
671679
device = torch.cuda.current_device()
672680
for micro in micro_batches:
681+
self._drop_pixel_values_on_non_first_pp_stage(micro)
673682
micro.to(device)
674683
sequences = micro["sequences"]
675684
attention_mask = micro["attention_mask"]
@@ -683,6 +692,7 @@ def forward(
683692
vlm_inputs = {}
684693
if micro.get("pixel_values") is not None:
685694
vlm_inputs["pixel_values"] = micro.get("pixel_values")
695+
if micro.get("image_grid_thw") is not None:
686696
vlm_inputs["image_grid_thw"] = micro.get("image_grid_thw")
687697

688698
micro_dicts.append(
@@ -718,6 +728,7 @@ def forward(
718728
all_metrics = defaultdict(list)
719729
all_loss_fn_outputs: List[Dict[str, Any]] = []
720730

731+
self._drop_pixel_values_on_non_first_pp_stage(data)
721732
# Move data to GPU
722733
data.to(torch.cuda.current_device())
723734

@@ -735,6 +746,7 @@ def forward(
735746
vlm_inputs = {}
736747
if experience.pixel_values is not None:
737748
vlm_inputs["pixel_values"] = experience.pixel_values
749+
if experience.image_grid_thw is not None:
738750
vlm_inputs["image_grid_thw"] = experience.image_grid_thw
739751

740752
micro_buffer.append(
@@ -828,6 +840,7 @@ def forward_backward(
828840
micro_batch_size = self.cfg.micro_train_batch_size_per_gpu
829841
all_metrics = defaultdict(list)
830842

843+
self._drop_pixel_values_on_non_first_pp_stage(data)
831844
# Move data to GPU
832845
data.to(torch.cuda.current_device())
833846

@@ -845,6 +858,7 @@ def forward_backward(
845858
vlm_inputs = {}
846859
if experience.pixel_values is not None:
847860
vlm_inputs["pixel_values"] = experience.pixel_values
861+
if experience.image_grid_thw is not None:
848862
vlm_inputs["image_grid_thw"] = experience.image_grid_thw
849863

850864
micro_buffer.append(
@@ -1222,19 +1236,13 @@ def forward(self, data: TrainingInputBatch) -> WorkerOutput:
12221236
if rollout_expert_indices is not None:
12231237
rollout_expert_indices = rollout_expert_indices.to(torch.int32)
12241238

1225-
vlm_inputs = {}
1226-
if micro.get("pixel_values") is not None:
1227-
vlm_inputs["pixel_values"] = micro.get("pixel_values")
1228-
vlm_inputs["image_grid_thw"] = micro.get("image_grid_thw")
1229-
12301239
micro_dicts.append(
12311240
{
12321241
"sequences": sequences,
12331242
"attention_mask": attention_mask,
12341243
"position_ids": position_ids,
12351244
"num_actions": num_actions,
12361245
"rollout_expert_indices": (rollout_expert_indices if self.enable_router_replay else None),
1237-
**vlm_inputs,
12381246
}
12391247
)
12401248

skyrl/train/sft_trainer.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -564,14 +564,17 @@ def _tokenize_chat_last_assistant(
564564
# token ids and any image tensors back out.
565565
processing_class = processor or tokenizer
566566

567+
length_kwargs = {}
568+
if max_length is not None and processor is None:
569+
length_kwargs = dict(truncation=True, max_length=max_length)
570+
567571
# Tokenize prompt (everything except last assistant message)
568572
prompt_ids = processing_class.apply_chat_template(
569573
messages[:-1],
570574
add_generation_prompt=True,
571575
tokenize=True,
572-
truncation=max_length is not None,
573-
max_length=max_length,
574576
return_dict=True,
577+
**length_kwargs,
575578
**tokenizer_kwargs,
576579
)
577580

@@ -580,29 +583,28 @@ def _tokenize_chat_last_assistant(
580583
messages,
581584
add_generation_prompt=False,
582585
tokenize=True,
583-
truncation=max_length is not None,
584-
max_length=max_length,
585586
return_dict=True,
587+
**length_kwargs,
586588
**tokenizer_kwargs,
587589
)
588590

589591
full_input_ids = _unbatch(full_ids["input_ids"])
590592
full_prompt_ids = _unbatch(prompt_ids["input_ids"])
591593

594+
# VLM samples can't be safely truncated (it would drop image placeholder tokens
595+
# and break image/text alignment), so drop anything that exceeds the limit.
596+
if processor is not None and max_length is not None and len(full_input_ids) > max_length:
597+
logger.warning(
598+
f"Dropping VLM sample longer than max_length={max_length}, consider increasing max_length if you see this warning too much"
599+
)
600+
return None
601+
592602
vlm_kwargs = {} # We only support Qwen-style image kwargs at the moment
593603
if "pixel_values" in full_ids and "image_grid_thw" in full_ids:
594604
vlm_kwargs = dict(
595605
pixel_values=full_ids["pixel_values"],
596606
image_grid_thw=full_ids["image_grid_thw"],
597607
)
598-
# If truncation dropped some <|image_pad|> tokens, the image tensors no
599-
# longer match the text and the sample must be skipped.
600-
merge_size = processor.image_processor.merge_size
601-
n_image_pads = full_input_ids.count(tokenizer.convert_tokens_to_ids("<|image_pad|>"))
602-
expected_image_pads = sum(int(thw.prod() // (merge_size**2)) for thw in full_ids["image_grid_thw"])
603-
if n_image_pads != expected_image_pads:
604-
logger.warning("Dropping sample due to truncated image, consider increasing max_length")
605-
return None
606608

607609
num_actions = len(full_input_ids) - len(full_prompt_ids)
608610
if num_actions <= 0:
@@ -724,8 +726,8 @@ def collate_sft_batch(examples: list, tokenizer) -> TrainingInputBatch:
724726
loss_masks.append([0] * action_pad + ex["loss_mask"])
725727

726728
if batch_has_images:
727-
pixel_values.append(ex["pixel_values"])
728-
image_grid_thw.append(ex["image_grid_thw"])
729+
pixel_values.append(torch.as_tensor(ex["pixel_values"]))
730+
image_grid_thw.append(torch.as_tensor(ex["image_grid_thw"]))
729731

730732
batch = TrainingInputBatch(
731733
{

0 commit comments

Comments
 (0)