Skip to content

Commit a8fc234

Browse files
committed
fix(data): address Energon tutorial review
Signed-off-by: yaoyu-33 <yaoyu.094@gmail.com>
1 parent 6b30ad0 commit a8fc234

2 files changed

Lines changed: 89 additions & 102 deletions

File tree

‎tutorials/data/energon/nemotron-image-v3.md‎

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,41 +101,36 @@ export PRETRAINED_CHECKPOINT=/checkpoints/Qwen3-VL-8B-Instruct
101101

102102
## 4. Launch Qwen3-VL LoRA through `train.sh`
103103

104-
The unified launcher selects the Qwen3-VL Energon task encoder with `--dataset energon`; the exact Energon recipe
105-
selects `vlm_step`. Mount the prepared dataset, checkpoint, and output paths into the training container:
104+
The exact recipe already selects LoRA, the Energon dataset config, and `vlm_step`; those launcher selectors do not need
105+
to be repeated. Mount the prepared dataset, checkpoint, and output paths into the training container:
106106

107107
```bash
108108
export OUTPUT_DIR=/results/qwen3-vl-nemotron-image-v3
109109
mkdir -p "$OUTPUT_DIR"
110110

111111
./scripts/training/train.sh \
112-
--nodes 1 --gpus-per-node 1 \
112+
--gpus-per-node 1 \
113113
--account ACCOUNT --partition PARTITION \
114114
--container-image /path/to/container.sqsh \
115115
--mount "$ENERGON_PATH" \
116116
--mount "$PRETRAINED_CHECKPOINT" \
117117
--mount "$OUTPUT_DIR" \
118118
--recipe qwen3_vl_8b_peft_energon_config \
119-
--step-func vlm_step \
120-
--mode lora --dataset energon \
121119
--seq_length 16384 \
122-
checkpoint.pretrained_checkpoint="$PRETRAINED_CHECKPOINT" \
120+
--pretrained_checkpoint "$PRETRAINED_CHECKPOINT" \
121+
--save_dir "$OUTPUT_DIR/checkpoints" \
122+
--save_interval 1 \
123+
--max_steps 1 \
124+
--global_batch_size 1 \
125+
--micro_batch_size 1 \
123126
checkpoint.load=null \
124-
checkpoint.save="$OUTPUT_DIR/checkpoints" \
125-
checkpoint.save_interval=1 \
126-
train.train_iters=1 \
127-
train.global_batch_size=1 \
128-
train.micro_batch_size=1 \
129127
validation.eval_interval=1 \
130128
validation.eval_iters=1 \
131-
validation.eval_micro_batch_size=1 \
132129
dataset.path="$ENERGON_PATH" \
133130
dataset.micro_batch_size=1 \
134131
dataset.num_workers=0 \
135-
dataset.num_val_workers=0 \
136132
dataset.packing_buffer_size=16 \
137133
dataset.task_encoder.hf_processor_revision="$MODEL_REVISION" \
138-
+tokenizer.hf_tokenizer_kwargs.revision="$MODEL_REVISION" \
139134
model.recompute_granularity=full \
140135
model.recompute_method=uniform \
141136
model.recompute_num_layers=1 \
@@ -148,10 +143,16 @@ mkdir -p "$OUTPUT_DIR"
148143
The pinned `turing` subset contains long reasoning targets: with the pinned Qwen3-VL processor and default image
149144
pixel bounds, the longest observed row is 15,608 tokens. The explicit 16,384-token setting keeps every source row
150145
eligible; the recipe's 4,096-token default would skip overlength rows. Full activation recompute keeps the documented
151-
one-GPU LoRA smoke within an 80 GiB H100. This one-step baseline intentionally uses no worker subprocesses so its
152-
command matches the validated smoke exactly. After it passes, increase `train.train_iters`, global batch size, and
153-
`dataset.num_workers`/`dataset.num_val_workers` for the target run. Benchmark recompute against context parallelism on
154-
the target hardware instead of silently reducing the dataset.
146+
one-GPU LoRA smoke within an 80 GiB H100. Native packing requires physical micro-batch size 1 in both the training and
147+
Energon configs, per-token loss, and non-averaged DDP collectives; these settings are not optional for this packed run.
148+
The processor revision is also required to reproduce the measured sequence lengths and pins the processor's tokenizer
149+
artifacts to the same model revision.
150+
151+
The one-step limit, global batch size 1, validation/checkpoint interval 1, worker count 0, and log interval 1 are
152+
smoke/debug settings rather than packing requirements. They preserve the validated baseline and make its loss and
153+
checkpoint evidence visible. After it passes, increase the step count, global batch size, and worker count for the
154+
target run. `num_val_workers` defaults to `num_workers`. Benchmark recompute against context parallelism on the target
155+
hardware instead of silently reducing the dataset.
155156

156157
For a local interactive smoke, export the same `ENERGON_PATH` and `PRETRAINED_CHECKPOINT`, then run
157158
`bash examples/models/qwen/qwen3_vl/peft_energon.sh`.

‎tutorials/data/energon/prepare_nemotron_image_v3.py‎

Lines changed: 70 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
import argparse
2727
import hashlib
28-
import io
2928
import json
3029
import logging
3130
import pickle
@@ -36,6 +35,8 @@
3635
from typing import Any
3736
from urllib.parse import urlparse
3837

38+
import webdataset as wds
39+
3940
from megatron.bridge.data.energon import prepare_webdataset
4041

4142

@@ -197,52 +198,34 @@ def read(self, reference: str) -> bytes:
197198
)
198199

199200

200-
class SplitShardWriter:
201-
"""Write deterministic split-prefixed WebDataset tar shards."""
202-
203-
def __init__(self, output_dir: Path, split: str, max_samples_per_tar: int) -> None:
204-
self.output_dir = output_dir
205-
self.split = split
206-
self.max_samples_per_tar = max_samples_per_tar
207-
self._archive: tarfile.TarFile | None = None
208-
self._shard_index = 0
209-
self._samples_in_shard = 0
210-
211-
def close(self) -> None:
212-
"""Close the current shard, if one was opened."""
213-
if self._archive is not None:
214-
self._archive.close()
215-
self._archive = None
201+
def _make_shard_writer(output_dir: Path, split: str, max_samples_per_tar: int) -> wds.ShardWriter:
202+
"""Create the standard WebDataset writer with deterministic tar metadata."""
203+
return wds.ShardWriter(
204+
str(output_dir / f"{split}-shard-%06d.tar"),
205+
maxcount=max_samples_per_tar,
206+
maxsize=float("inf"),
207+
verbose=0,
208+
encoder=False,
209+
mtime=0,
210+
mode=0o644,
211+
user="",
212+
group="",
213+
)
216214

217-
def __enter__(self) -> SplitShardWriter:
218-
return self
219215

220-
def __exit__(self, *args: object) -> None:
221-
self.close()
222-
223-
def _open_shard(self) -> None:
224-
self.close()
225-
shard_path = self.output_dir / f"{self.split}-shard-{self._shard_index:06d}.tar"
226-
self._archive = tarfile.open(shard_path, "w")
227-
self._shard_index += 1
228-
self._samples_in_shard = 0
229-
230-
def _add_bytes(self, name: str, payload: bytes) -> None:
231-
if self._archive is None:
232-
raise RuntimeError("Shard writer has no open archive.")
233-
info = tarfile.TarInfo(name=name)
234-
info.size = len(payload)
235-
info.mtime = 0
236-
info.mode = 0o644
237-
self._archive.addfile(info, io.BytesIO(payload))
238-
239-
def write(self, key: str, images: list[bytes], messages: list[dict[str, Any]]) -> None:
240-
"""Write one matching-key media list and conversation."""
241-
if self._archive is None or self._samples_in_shard >= self.max_samples_per_tar:
242-
self._open_shard()
243-
self._add_bytes(f"{key}.jpgs", pickle.dumps(images, protocol=4))
244-
self._add_bytes(f"{key}.json", json.dumps(messages, ensure_ascii=False).encode("utf-8"))
245-
self._samples_in_shard += 1
216+
def _write_sample(
217+
writer: wds.ShardWriter,
218+
key: str,
219+
images: list[bytes],
220+
messages: list[dict[str, Any]],
221+
) -> None:
222+
writer.write(
223+
{
224+
"__key__": key,
225+
"jpgs": pickle.dumps(images, protocol=4),
226+
"json": json.dumps(messages, ensure_ascii=False).encode("utf-8"),
227+
}
228+
)
246229

247230

248231
def _sample_split(key: str, validation_fraction: float) -> str:
@@ -357,46 +340,49 @@ def convert_subsets(
357340
seen_keys: set[str] = set()
358341
total = 0
359342

360-
with SplitShardWriter(output_dir, "train", max_samples_per_tar) as train_writer:
361-
with SplitShardWriter(output_dir, "val", max_samples_per_tar) as val_writer:
362-
writers = {"train": train_writer, "val": val_writer}
363-
for subset in subsets:
364-
subset_dir = (source_dir / subset).resolve()
365-
if not subset_dir.is_relative_to(source_dir) or not subset_dir.is_dir():
366-
raise FileNotFoundError(f"Downloaded subset directory does not exist: {subset_dir}")
367-
jsonl_path = _jsonl_path(subset_dir, subset)
368-
with (
369-
MediaResolver(
370-
subset_dir,
371-
allow_loose_media=allow_loose_media,
372-
archive_paths=None if media_archives is None else media_archives.get(subset, ()),
373-
) as resolver,
374-
jsonl_path.open(encoding="utf-8") as jsonl_file,
375-
):
376-
for line_number, line in enumerate(jsonl_file, start=1):
377-
if max_samples is not None and total >= max_samples:
378-
break
379-
if not line.strip():
380-
continue
381-
row = json.loads(line)
382-
sample_id = row.get("id")
383-
if not isinstance(sample_id, str) or not sample_id:
384-
raise ValueError(f"{jsonl_path}:{line_number}: row id must be a non-empty string.")
385-
key = _sample_key(subset, sample_id)
386-
if key in seen_keys:
387-
raise ValueError(f"{jsonl_path}:{line_number}: duplicate normalized sample key {key!r}.")
388-
seen_keys.add(key)
389-
messages, images = _normalize_messages(
390-
row.get("messages"),
391-
resolver=resolver,
392-
sample_name=f"{jsonl_path}:{line_number}",
343+
writers: dict[str, wds.ShardWriter] = {}
344+
with ExitStack() as writer_stack:
345+
for subset in subsets:
346+
subset_dir = (source_dir / subset).resolve()
347+
if not subset_dir.is_relative_to(source_dir) or not subset_dir.is_dir():
348+
raise FileNotFoundError(f"Downloaded subset directory does not exist: {subset_dir}")
349+
jsonl_path = _jsonl_path(subset_dir, subset)
350+
with (
351+
MediaResolver(
352+
subset_dir,
353+
allow_loose_media=allow_loose_media,
354+
archive_paths=None if media_archives is None else media_archives.get(subset, ()),
355+
) as resolver,
356+
jsonl_path.open(encoding="utf-8") as jsonl_file,
357+
):
358+
for line_number, line in enumerate(jsonl_file, start=1):
359+
if max_samples is not None and total >= max_samples:
360+
break
361+
if not line.strip():
362+
continue
363+
row = json.loads(line)
364+
sample_id = row.get("id")
365+
if not isinstance(sample_id, str) or not sample_id:
366+
raise ValueError(f"{jsonl_path}:{line_number}: row id must be a non-empty string.")
367+
key = _sample_key(subset, sample_id)
368+
if key in seen_keys:
369+
raise ValueError(f"{jsonl_path}:{line_number}: duplicate normalized sample key {key!r}.")
370+
seen_keys.add(key)
371+
messages, images = _normalize_messages(
372+
row.get("messages"),
373+
resolver=resolver,
374+
sample_name=f"{jsonl_path}:{line_number}",
375+
)
376+
split = _sample_split(key, validation_fraction)
377+
if split not in writers:
378+
writers[split] = writer_stack.enter_context(
379+
_make_shard_writer(output_dir, split, max_samples_per_tar)
393380
)
394-
split = _sample_split(key, validation_fraction)
395-
writers[split].write(key, images, messages)
396-
counts[split] += 1
397-
total += 1
398-
if max_samples is not None and total >= max_samples:
399-
break
381+
_write_sample(writers[split], key, images, messages)
382+
counts[split] += 1
383+
total += 1
384+
if max_samples is not None and total >= max_samples:
385+
break
400386

401387
if counts["train"] == 0:
402388
raise RuntimeError("No training samples were written.")

0 commit comments

Comments
 (0)