Skip to content

Commit 56a3a80

Browse files
alay2shahAlay Shah
andauthored
Optimize training data paths and enable local one-GPU training (#41)
* Use shared trainer loops for single-GPU SFT * Enable local single-GPU VLM DPO * Enable local single-GPU MoE LoRA training * Add local single-GPU E2E coverage * Clarify MoE dataset setup comment * Run E2E tests through default dispatcher * Optimize Ray training data paths * Simplify local trainer eligibility --------- Co-authored-by: Alay Shah <alay.shah@liquid.ai>
1 parent 0c5064f commit 56a3a80

28 files changed

Lines changed: 740 additions & 80 deletions

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Liquid AI
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,10 @@ Launch training:
203203
uv run leap-finetune job_configs/sft_example.yaml
204204
```
205205

206-
Training uses Ray Train and Accelerate for distributed execution. SFT, DPO, VLM SFT, and VLM DPO automatically use the native Hugging Face Trainer when exactly one GPU
207-
is visible; set `LEAP_LAUNCHER=ray` to force Ray. Unless
206+
Training uses Ray Train and Accelerate for distributed execution. SFT, DPO, VLM
207+
SFT, and VLM DPO automatically use the native Hugging Face Trainer when exactly
208+
one GPU is visible. Text and VLM GRPO use the native Trainer only for one-GPU
209+
`vllm_mode: colocate` runs. Set `LEAP_LAUNCHER=ray` to force Ray. Unless
208210
`output_dir` is set, results are written to
209211
`outputs/{project_name}/{run_name}/`. Each run gets a unique name based on the
210212
model, dataset, learning rate, and timestamp.
@@ -262,7 +264,9 @@ uvx --from . leap-finetune /absolute/path/to/config.yaml
262264

263265
You can also start a run from Python. This uses the same backend dispatch as
264266
the CLI: configs with `slurm`, `modal`, or `kuberay` submit remotely; other
265-
configs run local training and require visible CUDA devices. SFT, DPO, VLM SFT, and VLM DPO use the native Trainer on one GPU; other modes use Ray.
267+
configs run local training and require visible CUDA devices. SFT, DPO, VLM SFT,
268+
and VLM DPO use the native Trainer on one GPU; GRPO uses it only for one-GPU
269+
colocated vLLM; other modes use Ray.
266270

267271
```python
268272
from leap_finetune import run_config

src/leap_finetune/data_loading/image_loader.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import io
22
import logging
3+
import os
4+
from collections import OrderedDict
5+
from threading import RLock
36

47
import requests
58
from PIL import Image, ImageFile
@@ -10,6 +13,44 @@
1013

1114
logger = logging.getLogger(__name__)
1215

16+
_IMAGE_CACHE_MAX_ITEMS = 32
17+
_IMAGE_CACHE: OrderedDict[tuple[str, int, int], Image.Image] = OrderedDict()
18+
_IMAGE_CACHE_LOCK = RLock()
19+
20+
21+
def _load_cached_path_image(src) -> Image.Image:
22+
"""Load a local image with a small per-process cache.
23+
24+
Return a copy so callers may close their image without invalidating the
25+
cached object. File size and mtime invalidate entries when a path changes.
26+
"""
27+
path = os.fspath(src)
28+
try:
29+
stat = os.stat(path)
30+
except OSError:
31+
with Image.open(path) as image:
32+
return image.convert("RGB")
33+
34+
key = (path, stat.st_mtime_ns, stat.st_size)
35+
with _IMAGE_CACHE_LOCK:
36+
cached = _IMAGE_CACHE.pop(key, None)
37+
if cached is not None:
38+
_IMAGE_CACHE[key] = cached
39+
return cached.copy()
40+
41+
with Image.open(path) as image:
42+
decoded = image.convert("RGB")
43+
44+
with _IMAGE_CACHE_LOCK:
45+
for old_key in list(_IMAGE_CACHE):
46+
if old_key[0] == path:
47+
_IMAGE_CACHE.pop(old_key).close()
48+
_IMAGE_CACHE[key] = decoded
49+
while len(_IMAGE_CACHE) > _IMAGE_CACHE_MAX_ITEMS:
50+
_, evicted = _IMAGE_CACHE.popitem(last=False)
51+
evicted.close()
52+
return decoded.copy()
53+
1354

1455
def load_image(src) -> Image.Image:
1556
"""Load image from various sources and return PIL Image in RGB."""
@@ -24,7 +65,26 @@ def load_image(src) -> Image.Image:
2465
resp.raise_for_status()
2566
return Image.open(resp.raw).convert("RGB")
2667
# file path -> PIL
27-
return Image.open(src).convert("RGB")
68+
return _load_cached_path_image(src)
69+
70+
71+
def get_image_size(src) -> tuple[int, int]:
72+
"""Read image dimensions without decoding the full local image."""
73+
if isinstance(src, (bytes, bytearray)):
74+
with Image.open(io.BytesIO(src)) as image:
75+
return image.size
76+
if isinstance(src, str) and src.startswith(("http://", "https://")):
77+
resp = requests.get(
78+
src, stream=True, headers={"User-Agent": "leap-finetune"}, timeout=15
79+
)
80+
try:
81+
resp.raise_for_status()
82+
with Image.open(resp.raw) as image:
83+
return image.size
84+
finally:
85+
resp.close()
86+
with Image.open(src) as image:
87+
return image.size
2888

2989

3090
def is_image_loadable(src: str) -> bool:

src/leap_finetune/data_loading/length_grouping.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,27 @@ def get_length_grouped_sampler(
4141
model_input_name="input_ids",
4242
generator=generator,
4343
)
44+
45+
46+
def get_tile_count_grouped_sampler(
47+
dataset: Dataset | None,
48+
batch_size: int,
49+
*,
50+
generator: torch.Generator | None = None,
51+
):
52+
"""Group VLM examples with similar processed image tile counts."""
53+
if dataset is None:
54+
return None
55+
column_names = getattr(dataset, "column_names", None)
56+
if column_names is None or "_vlm_tile_count" not in column_names:
57+
return None
58+
counts = list(dataset["_vlm_tile_count"])
59+
if not counts:
60+
return None
61+
if len(set(counts)) == 1:
62+
return None
63+
return LengthGroupedSampler(
64+
batch_size=batch_size,
65+
lengths=[max(1, int(count)) for count in counts],
66+
generator=generator,
67+
)

src/leap_finetune/data_loading/ray_data_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ def _tokenize_datasets(
319319
eval_ds,
320320
tokenizer,
321321
max_length,
322-
packing=False,
322+
packing=packing,
323323
assistant_only_loss=assistant_only_loss,
324324
completion_only_loss=completion_only_loss,
325325
drop_overlength=drop_overlength,

src/leap_finetune/data_loading/tokenize_data.py

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
import ray
55
import ray.data
66
import torch
7-
from datasets import Dataset
8-
import pyarrow as pa
7+
from datasets import Dataset, Features, Sequence, Value
98
from rich.console import Console
109
from trl.data_utils import maybe_apply_chat_template, maybe_extract_prompt
1110
from trl.data_utils import pack_dataset
@@ -268,26 +267,20 @@ def tokenize_and_pack_sft(
268267

269268
# === 2. Pack or truncate ===
270269
if packing:
271-
# Keep packing Arrow-native; the previous row list doubled peak memory.
272-
if hasattr(ds, "to_arrow_refs"):
273-
tables = ray.get(ds.to_arrow_refs())
274-
if not tables:
275-
return ds
276-
table = pa.concat_tables(tables)
277-
columns = [
278-
name
279-
for name in ("input_ids", "assistant_masks", "completion_mask")
280-
if name in table.column_names
281-
]
282-
hf_ds = Dataset(table.select(columns))
283-
else:
284-
hf_ds = Dataset.from_generator(ds.iter_rows)
285-
columns = [
286-
name
287-
for name in ("input_ids", "assistant_masks", "completion_mask")
288-
if name in hf_ds.column_names
289-
]
290-
hf_ds = hf_ds.select_columns(columns)
270+
# Packing requires full materialization into an HF Dataset
271+
rows = []
272+
features_dict = {"input_ids": Sequence(Value("int64"))}
273+
for row in ds.iter_rows():
274+
packed_row = {"input_ids": row["input_ids"]}
275+
if "assistant_masks" in row:
276+
packed_row["assistant_masks"] = row["assistant_masks"]
277+
features_dict["assistant_masks"] = Sequence(Value("int64"))
278+
if "completion_mask" in row:
279+
packed_row["completion_mask"] = row["completion_mask"]
280+
features_dict["completion_mask"] = Sequence(Value("int64"))
281+
rows.append(packed_row)
282+
features = Features(features_dict)
283+
hf_ds = Dataset.from_list(rows, features=features)
291284
console.print(f"[dim]Tokenized {len(hf_ds):,} rows[/dim]")
292285
console.print(f"[dim]Packing sequences (BFD, max_length={max_length})...[/dim]")
293286
hf_ds = pack_dataset(hf_ds, seq_length=max_length, strategy="bfd")
@@ -347,10 +340,15 @@ def tokenize_dpo(
347340

348341
# Column names must match TRL v1's DPO data collator:
349342
# prompt_ids, chosen_ids, rejected_ids (changed from *_input_ids in TRL 0.x)
343+
# Include the actual collated sequence length so length grouping works for DPO.
350344
return {
351345
"prompt_ids": list(prompt_input_ids),
352346
"chosen_ids": list(chosen_input_ids),
353347
"rejected_ids": list(rejected_input_ids),
348+
"length": max(
349+
len(prompt_input_ids) + len(chosen_input_ids),
350+
len(prompt_input_ids) + len(rejected_input_ids),
351+
),
354352
}
355353

356354

@@ -374,12 +372,7 @@ def tokenize_dpo_dataset(
374372
"max_completion_length": max_completion_length,
375373
},
376374
)
377-
378-
arrow_refs = ds.to_arrow_refs()
379-
if not arrow_refs:
380-
return ds
381-
382-
tables = ray.get(arrow_refs)
383-
row_count = sum(table.num_rows for table in tables)
384-
console.print(f"[dim]Tokenized {row_count:,} DPO rows[/dim]")
385-
return ray.data.from_arrow(pa.concat_tables(tables))
375+
# Keep tokenization lazy in Ray. The driver must not gather all tokenized
376+
# DPO rows with ray.get(); Ray Train shards the dataset first, and each
377+
# worker materializes only its local shard for the HF trainer.
378+
return ds
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import logging
2+
from collections.abc import Iterator
3+
4+
from datasets import Dataset
5+
6+
from leap_finetune.data_loading.image_loader import get_image_size
7+
8+
logger = logging.getLogger(__name__)
9+
VLM_TILE_COUNT_COLUMN = "_vlm_tile_count"
10+
11+
12+
def _image_sources(value) -> Iterator[str]:
13+
if isinstance(value, dict):
14+
if value.get("type") == "image" and isinstance(value.get("image"), str):
15+
yield value["image"]
16+
return
17+
for child in value.values():
18+
yield from _image_sources(child)
19+
elif isinstance(value, (list, tuple)):
20+
for child in value:
21+
yield from _image_sources(child)
22+
23+
24+
def _row_image_sources(row: dict) -> Iterator[str]:
25+
for key in ("messages", "prompt", "chosen", "rejected"):
26+
if key in row:
27+
yield from _image_sources(row[key])
28+
if isinstance(row.get("image"), str):
29+
yield row["image"]
30+
if isinstance(row.get("images"), (list, tuple)):
31+
yield from (image for image in row["images"] if isinstance(image, str))
32+
33+
34+
def _tile_count(image_processor, height: int, width: int) -> int:
35+
"""Match LFM2-VL's grid decision without running pixel preprocessing."""
36+
if not getattr(image_processor, "do_image_splitting", False):
37+
return 1
38+
39+
is_too_large = getattr(image_processor, "_is_image_too_large", None)
40+
get_grid_layout = getattr(image_processor, "_get_grid_layout", None)
41+
if is_too_large is None or get_grid_layout is None:
42+
return 1
43+
44+
kwargs = {
45+
"max_image_tokens": int(getattr(image_processor, "max_image_tokens", 256)),
46+
"encoder_patch_size": int(getattr(image_processor, "encoder_patch_size", 16)),
47+
"downsample_factor": int(getattr(image_processor, "downsample_factor", 2)),
48+
"max_pixels_tolerance": float(
49+
getattr(image_processor, "max_pixels_tolerance", 2.0)
50+
),
51+
}
52+
if not is_too_large(height=height, width=width, **kwargs):
53+
return 1
54+
55+
_, _, _, _, tiles = get_grid_layout(
56+
height=height,
57+
width=width,
58+
min_tiles=int(getattr(image_processor, "min_tiles", 2)),
59+
max_tiles=int(getattr(image_processor, "max_tiles", 10)),
60+
tile_size=int(getattr(image_processor, "tile_size", 512)),
61+
)
62+
if getattr(image_processor, "use_thumbnail", True) and tiles > 1:
63+
tiles += 1
64+
return int(tiles)
65+
66+
67+
def estimate_vlm_tile_count(row: dict, processor) -> int:
68+
"""Estimate processed visual tiles for one normalized VLM row."""
69+
image_processor = getattr(processor, "image_processor", None)
70+
if image_processor is None:
71+
return 0
72+
73+
count = 0
74+
for source in _row_image_sources(row):
75+
try:
76+
width, height = get_image_size(source)
77+
count += _tile_count(image_processor, height, width)
78+
except Exception:
79+
logger.debug(
80+
"Could not estimate VLM tile count for %s", source, exc_info=True
81+
)
82+
count += 1
83+
return count
84+
85+
86+
def add_vlm_tile_counts(dataset: Dataset | None, processor) -> Dataset | None:
87+
"""Add local, non-training metadata used by the VLM batch sampler."""
88+
if dataset is None or VLM_TILE_COUNT_COLUMN in dataset.column_names:
89+
return dataset
90+
counts = [estimate_vlm_tile_count(row, processor) for row in dataset]
91+
return dataset.add_column(VLM_TILE_COUNT_COLUMN, counts)

src/leap_finetune/distribution/local_trainer.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
)
1313
from leap_finetune.training import TRAINING_LOOPS
1414

15-
_LOCAL_TYPES = frozenset({"sft", "dpo", "vlm_sft", "vlm_dpo"})
15+
_LOCAL_TYPES = frozenset(
16+
{"sft", "dpo", "vlm_sft", "vlm_dpo", "grpo", "vlm_grpo", "moe_sft", "moe_dpo"}
17+
)
1618

1719

1820
def should_use_local(job_config: dict) -> bool:
@@ -21,11 +23,15 @@ def should_use_local(job_config: dict) -> bool:
2123
return False
2224
training_type = job_config["training_type"]
2325
is_moe = is_moe_model_from_name(job_config["model_name"])
24-
if training_type not in _LOCAL_TYPES and training_type not in {
25-
"moe_sft",
26-
"moe_dpo",
27-
}:
26+
if training_type not in _LOCAL_TYPES:
2827
return False
28+
if training_type in {"grpo", "vlm_grpo"}:
29+
train_config = job_config.get("training_config") or {}
30+
if train_config.get("vllm_mode", "colocate") != "colocate":
31+
return False
32+
rollout_config = job_config.get("grpo_rollout") or {}
33+
if int(rollout_config.get("tensor_parallel_size", 1) or 1) != 1:
34+
return False
2935
if training_type.startswith("moe_") and not is_moe:
3036
return False
3137
if is_moe:
@@ -90,6 +96,7 @@ def local_trainer(job_config: dict):
9096
"model_config": job_config.get("model_config"),
9197
"benchmark_configs": job_config.get("benchmark_configs"),
9298
"rewards": job_config.get("rewards"),
99+
"rl_env": job_config.get("rl_env"),
93100
"async_eval": job_config.get("async_eval"),
94101
"config_dir": job_config.get("config_dir"),
95102
}

0 commit comments

Comments
 (0)