Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / data / megatron / gpt_dataset.py: 17%

143 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-13 05:07 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""``GPTDataset`` — fixed-length samples drawn from a Megatron IndexedDataset. 

16 

17Behaviourally aligned with Megatron-LM's ``GPTDataset``: 

18 

191. ``document_index`` — a shuffled flat array repeating every document for 

20 ``num_epochs`` epochs. Documents stay whole; only their visit order is 

21 randomised. 

222. ``sample_index`` — for sample ``i`` the entry ``[doc_id, doc_offset]`` 

23 points to the document and within-document token offset where that 

24 sample starts. Built by sequentially walking the document stream and 

25 slicing every ``seq_length`` tokens. 

263. ``shuffle_index`` — a random permutation applied on top, so sample 

27 ``__getitem__(i)`` actually reads sample ``shuffle_index[i]``. 

28 

29A sample may span multiple documents (Megatron's concatenated-doc style); 

30in that case we glue the relevant slices together until we reach 

31``seq_length + 1`` tokens (one extra so labels can be shifted, exactly 

32Megatron's ``add_extra_token_to_sequence=1``). If the very last sample 

33runs out of documents, the tail is right-padded with ``pad_token_id``. 

34 

35Pure Python+numpy. No Cython helpers — the index construction runs once 

36per ``(seed, prefix, num_samples, seq_length, num_epochs)`` tuple and is 

37cached to disk under ``<prefix>_cache/`` so subsequent runs reuse it. 

38""" 

39import functools 

40import hashlib 

41import logging 

42import os 

43import tempfile 

44from typing import Any, Dict, Optional, Tuple 

45 

46import numpy as np 

47import torch 

48from torch.utils.data import Dataset 

49 

50from hyper_parallel.data.megatron.indexed_dataset import IndexedDataset 

51 

52 

53logger = logging.getLogger(__name__) 

54 

55 

56def _build_document_index(num_documents: int, num_epochs: int, rng: np.random.RandomState) -> np.ndarray: 

57 """Return a globally-shuffled, ``num_epochs``-repeated array of document indices. 

58 

59 Matches Megatron-LM's ``separate_last_epoch=False`` default: tile the 

60 document range across ``num_epochs`` and run a single global shuffle. 

61 This intentionally lets a document appear several times before another 

62 appears once — that randomness is part of the canonical training 

63 distribution and the realised epoch-boundary noise is what Megatron's 

64 schedulers expect. 

65 """ 

66 doc_idx = np.tile(np.arange(num_documents, dtype=np.int32), num_epochs) 

67 rng.shuffle(doc_idx) 

68 return doc_idx 

69 

70 

71def _build_sample_index( 

72 sizes: np.ndarray, 

73 doc_idx: np.ndarray, 

74 seq_length: int, 

75 tokens_per_epoch: int, 

76 num_epochs: int, 

77) -> np.ndarray: 

78 """Construct the ``[num_samples + 1, 2]`` (doc_id, doc_offset) sample table. 

79 

80 Walks the per-document token stream in ``doc_idx`` order, marking the 

81 start of every ``seq_length``-window. Each sample includes one extra 

82 token so labels can be shifted by 1; the helper keeps the 

83 pre-Megatron-Cython slow-path semantics (no boundary correction — 

84 multi-document samples are reassembled by :meth:`GPTDataset._read_sample`). 

85 """ 

86 tokens_to_consume = num_epochs * tokens_per_epoch - 1 # -1 to keep ``+1`` extra token in reach 

87 sample_stride = seq_length 

88 num_samples = tokens_to_consume // sample_stride 

89 # int64: column 0 indexes ``doc_idx`` whose length is 

90 # ``num_documents * num_epochs`` — int32 would wrap past ~2.1e9 slots on 

91 # large corpora trained for many epochs, silently selecting the wrong 

92 # document. 

93 sample_idx = np.zeros((num_samples + 1, 2), dtype=np.int64) 

94 

95 cur_doc_index = 0 

96 cur_doc_offset = 0 

97 cur_doc_remaining = int(sizes[doc_idx[cur_doc_index]]) 

98 sample_idx[0, 0] = cur_doc_index 

99 sample_idx[0, 1] = cur_doc_offset 

100 for sample_id in range(1, num_samples + 1): 

101 remaining = sample_stride 

102 while remaining > 0: 

103 if cur_doc_remaining > remaining: 

104 cur_doc_offset += remaining 

105 cur_doc_remaining -= remaining 

106 remaining = 0 

107 else: 

108 remaining -= cur_doc_remaining 

109 cur_doc_index += 1 

110 if cur_doc_index >= doc_idx.shape[0]: 

111 # Document stream exhausted — record where we landed 

112 # and stop. Caller will pad the tail. 

113 cur_doc_remaining = 0 

114 break 

115 cur_doc_offset = 0 

116 cur_doc_remaining = int(sizes[doc_idx[cur_doc_index]]) 

117 sample_idx[sample_id, 0] = cur_doc_index 

118 sample_idx[sample_id, 1] = cur_doc_offset 

119 return sample_idx 

120 

121 

122def _build_shuffle_index(num_samples: int, rng: np.random.RandomState) -> np.ndarray: 

123 """Random permutation of ``[0, num_samples)``.""" 

124 shuffle = np.arange(num_samples, dtype=np.int64) 

125 rng.shuffle(shuffle) 

126 return shuffle 

127 

128 

129def _layout_fingerprint(doc_sizes: np.ndarray) -> str: 

130 """Hash the per-document token-count array. 

131 

132 ``doc_sizes`` is exactly what determines every ``sample_idx`` offset, so a 

133 corpus regenerated at the same path with a different document layout — even 

134 one preserving document count and total token count — produces a different 

135 fingerprint and invalidates the stale cache. 

136 """ 

137 return hashlib.sha1(np.ascontiguousarray(doc_sizes, dtype=np.int64).tobytes()).hexdigest()[:16] 

138 

139 

140def _cache_key( 

141 prefix: str, 

142 seq_length: int, 

143 num_samples: int, 

144 num_epochs: int, 

145 seed: int, 

146 layout_fp: str, 

147) -> str: 

148 """Stable filename suffix for the ``(doc, sample, shuffle)`` index trio. 

149 

150 ``layout_fp`` fingerprints the underlying corpus layout (see 

151 :func:`_layout_fingerprint`) so that regenerating the ``.bin``/``.idx`` 

152 under the same path invalidates a stale cache instead of silently 

153 replaying indices computed against the old token stream. 

154 """ 

155 h = hashlib.sha1( 

156 f"{prefix}|{seq_length}|{num_samples}|{num_epochs}|{seed}|{layout_fp}".encode("utf-8"), 

157 ).hexdigest()[:16] 

158 return h 

159 

160 

161class GPTDataset(Dataset): 

162 """Megatron-style GPT dataset on top of an :class:`IndexedDataset`. 

163 

164 Args: 

165 indexed_dataset: Underlying token-stream reader. 

166 num_samples: How many fixed-length samples to expose. Typically 

167 ``max_steps * global_batch_size`` so the dataloader never 

168 runs dry mid-training. 

169 seq_length: Tokens per ``input_ids`` (excluding the extra label token). 

170 seed: RNG seed for the document / shuffle permutations. 

171 pad_token_id: Token used to right-pad the very last sample when 

172 the document stream is exhausted before ``seq_length + 1`` 

173 tokens are available. 

174 eod_mask_loss: When ``True``, the EOD token's label is replaced by 

175 ``-100`` so it does not contribute to the loss. 

176 eod_token_id: EOD token id; ignored when ``eod_mask_loss`` is False. 

177 cache_dir: Where to cache the (doc, sample, shuffle) index arrays. 

178 Defaults to ``<indexed_dataset.path_prefix>_cache``. 

179 

180 Returns from ``__getitem__``: 

181 ``{"input_ids": Tensor[seq_length], "labels": Tensor[seq_length]}`` 

182 — labels is a copy of input_ids (the model handles next-token 

183 shifting internally, matching the rest of the trainer's 

184 dataset contract). 

185 """ 

186 

187 def __init__( 

188 self, 

189 indexed_dataset: IndexedDataset, 

190 num_samples: int, 

191 seq_length: int, 

192 seed: int = 1234, 

193 *, 

194 pad_token_id: int = 0, 

195 eod_mask_loss: bool = False, 

196 eod_token_id: Optional[int] = None, 

197 cache_dir: Optional[str] = None, 

198 ) -> None: 

199 if num_samples <= 0: 

200 raise ValueError(f"num_samples must be > 0, got {num_samples}") 

201 if seq_length <= 0: 

202 raise ValueError(f"seq_length must be > 0, got {seq_length}") 

203 

204 self.indexed_dataset = indexed_dataset 

205 self.num_samples = int(num_samples) 

206 self.seq_length = int(seq_length) 

207 self.seed = int(seed) 

208 self.pad_token_id = int(pad_token_id) 

209 self.eod_mask_loss = bool(eod_mask_loss) 

210 self.eod_token_id = eod_token_id 

211 

212 self.document_index, self.sample_index, self.shuffle_index = self._build_indices(cache_dir) 

213 # Adjacent samples often touch the same document — cache the concat 

214 # result for the last few requested doc ids so straddling reads 

215 # don't re-walk the IndexedDataset. 

216 self._cached_doc = functools.lru_cache(maxsize=128)(self._read_doc) 

217 

218 def _build_indices(self, cache_dir: Optional[str]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: 

219 """Build (or load from cache) the three Megatron-style indices.""" 

220 sizes = self.indexed_dataset.sequence_lengths 

221 num_docs = self.indexed_dataset.num_documents 

222 if num_docs == 0: 

223 raise ValueError("IndexedDataset has no documents to sample from") 

224 # Build a per-document length array using the existing 

225 # ``document_indices`` boundaries. 

226 doc_boundaries = self.indexed_dataset.document_indices 

227 doc_sizes = np.zeros(num_docs, dtype=np.int64) 

228 for d in range(num_docs): 

229 start = int(doc_boundaries[d]) 

230 end = int(doc_boundaries[d + 1]) 

231 doc_sizes[d] = int(sizes[start:end].sum()) 

232 

233 tokens_per_epoch = int(doc_sizes.sum()) 

234 if tokens_per_epoch == 0: 

235 raise ValueError("IndexedDataset has zero total tokens") 

236 # ``+1`` because each sample reads ``seq_length + 1`` tokens (input 

237 # plus the next-token label). Ceil so we always cover ``num_samples``. 

238 tokens_required = self.num_samples * self.seq_length + 1 

239 num_epochs = max(1, int(np.ceil(tokens_required / tokens_per_epoch))) 

240 

241 cache_dir = self._resolve_cache_dir(cache_dir) 

242 key = _cache_key( 

243 self.indexed_dataset.path_prefix, self.seq_length, 

244 self.num_samples, num_epochs, self.seed, 

245 _layout_fingerprint(doc_sizes), 

246 ) 

247 doc_path = os.path.join(cache_dir, f"doc_idx_{key}.npy") 

248 sample_path = os.path.join(cache_dir, f"sample_idx_{key}.npy") 

249 shuffle_path = os.path.join(cache_dir, f"shuffle_idx_{key}.npy") 

250 

251 if os.path.isfile(doc_path) and os.path.isfile(sample_path) and os.path.isfile(shuffle_path): 

252 return np.load(doc_path), np.load(sample_path), np.load(shuffle_path) 

253 

254 rng = np.random.RandomState(self.seed) 

255 doc_idx = _build_document_index(num_docs, num_epochs, rng) 

256 sample_idx = _build_sample_index( 

257 doc_sizes, doc_idx, self.seq_length, tokens_per_epoch, num_epochs, 

258 ) 

259 # ``sample_idx`` has at most ``num_samples + 1`` rows; clip in case 

260 # we built slightly more (rounded up by tokens_per_epoch / seq_len). 

261 usable_samples = max(sample_idx.shape[0] - 1, 1) 

262 shuffle_idx = _build_shuffle_index(min(self.num_samples, usable_samples), rng) 

263 np.save(doc_path, doc_idx) 

264 np.save(sample_path, sample_idx) 

265 np.save(shuffle_path, shuffle_idx) 

266 return doc_idx, sample_idx, shuffle_idx 

267 

268 def _resolve_cache_dir(self, user_cache_dir: Optional[str]) -> str: 

269 """Return a writable cache directory, falling back to ``$TMPDIR``. 

270 

271 Defaulting to ``<prefix>_cache`` next to the corpus is convenient, 

272 but production datasets often sit on read-only shared mounts where 

273 ``os.makedirs`` would raise ``EROFS``. Detect and silently relocate. 

274 """ 

275 candidate = user_cache_dir or (self.indexed_dataset.path_prefix + "_cache") 

276 try: 

277 os.makedirs(candidate, exist_ok=True) 

278 # Probe writability — ``os.makedirs`` succeeds on directories 

279 # that exist read-only. 

280 probe = os.path.join(candidate, ".hp_cache_probe") 

281 with open(probe, "w", encoding="utf-8") as f: 

282 f.write("") 

283 os.remove(probe) 

284 return candidate 

285 except OSError as exc: 

286 fallback = os.path.join(tempfile.gettempdir(), "hp_megatron_cache") 

287 os.makedirs(fallback, exist_ok=True) 

288 logger.warning( 

289 "GPTDataset cache dir %s not writable (%s); using fallback %s", 

290 candidate, exc, fallback, 

291 ) 

292 return fallback 

293 

294 def __len__(self) -> int: 

295 return min(self.num_samples, int(self.shuffle_index.size)) 

296 

297 def _read_sample(self, sample_pos: int) -> np.ndarray: 

298 """Concatenate the ``seq_length + 1`` tokens for sample ``sample_pos``.""" 

299 start_doc_idx, start_offset = self.sample_index[sample_pos] 

300 end_doc_idx, end_offset = self.sample_index[sample_pos + 1] 

301 parts = [] 

302 if start_doc_idx == end_doc_idx: 

303 doc_tokens = self._cached_doc(int(self.document_index[start_doc_idx])) 

304 parts.append(doc_tokens[start_offset:end_offset + 1]) 

305 else: 

306 doc_tokens = self._cached_doc(int(self.document_index[start_doc_idx])) 

307 parts.append(doc_tokens[start_offset:]) 

308 for doc_id in range(start_doc_idx + 1, end_doc_idx): 

309 parts.append(self._cached_doc(int(self.document_index[doc_id]))) 

310 tail_doc_tokens = self._cached_doc(int(self.document_index[end_doc_idx])) 

311 parts.append(tail_doc_tokens[:end_offset + 1]) 

312 out = np.concatenate(parts) if len(parts) > 1 else parts[0] 

313 needed = self.seq_length + 1 

314 if out.size >= needed: 

315 return out[:needed] 

316 # Pad short tail (only happens for the very last sample). 

317 pad = np.full(needed - out.size, self.pad_token_id, dtype=out.dtype) 

318 return np.concatenate([out, pad]) 

319 

320 def _read_doc(self, doc_id: int) -> np.ndarray: 

321 """All tokens of ``doc_id`` as a single 1-D array (memoised).""" 

322 return self.indexed_dataset.get_document(doc_id) 

323 

324 def __getitem__(self, idx: int) -> Dict[str, Any]: 

325 actual = int(self.shuffle_index[idx]) 

326 tokens = self._read_sample(actual) 

327 input_ids = torch.from_numpy(tokens[: self.seq_length].astype(np.int64)) 

328 labels = input_ids.clone() 

329 if self.eod_mask_loss and self.eod_token_id is not None: 

330 labels[input_ids == int(self.eod_token_id)] = -100 

331 return {"input_ids": input_ids, "labels": labels}