Skip to content

Commit 92f5eca

Browse files
fix(storage#795): default run num_files_train to the datasize minimum (#804)
The reporter's run wanted 1,170,301 files while `datasize` recommended 257,173, and aborted because the objects did not exist. Root cause: the run reads `dataset.num_files_train` from the workload YAML reference default (retinanet_b200: 1,170,301) whenever the user does not override it. `datasize` computes a smaller, memory-derived minimum for the system. Because `skip_listing` is forced on, each rank reconstructs filenames as `{prefix}_{idx}_of_{num_files_train}.{ext}` instead of listing the directory, so a run defaulting to 1,170,301 reconstructs `_of_1170301` names that miss every `_of_257173` object the user generated per the datasize/datagen hint. Add `_resolve_num_files_train()`: on the run/configview path, when the user has not passed `--params dataset.num_files_train`, default it to the datasize-computed minimum (same cluster_information + declared-vs-measured reconciliation datasize uses), so the interval calc, the run, and check_num_files_train all agree and the reconstructed names match the generated dataset. Deliberately scoped: - Never touches datagen. The generation host set (and its memory) typically differs from the run host set, and submitters routinely over-generate (up to ~10x) to reuse one dataset; generation size is the submitter's choice, threaded explicitly via the datasize->datagen hint. datagen has no cluster_information, so the method no-ops. - Respects an explicit --params dataset.num_files_train override — a submitter running against a deliberately larger generated set sets it and that value wins. dataset.num_files_train is already CLOSED_ALLOWED, so the auto-resolved value passes the run-checker whether user- or tool-originated; updated the TOOL_INJECTED_PARAMS comment in training.py to note the new tool origin. Adds tests/unit/test_resolve_num_files_train.py (5 cases: resolve, explicit-override respected, datagen no-op, computed==default no-op, graceful compute-failure).
1 parent 7ac083c commit 92f5eca

3 files changed

Lines changed: 236 additions & 2 deletions

File tree

mlpstorage_py/benchmarks/dlio.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,106 @@ def _apply_skip_listing_params(self, num_files_override: Optional[int] = None):
336336
f'(~{checks:,} HEAD checks at startup)'
337337
)
338338

339+
def _resolve_num_files_train(self):
340+
"""storage#795: default ``dataset.num_files_train`` to the datasize
341+
minimum for THIS run instead of the workload YAML reference value.
342+
343+
The ``num_files_train`` in the workload YAML is a reference placeholder
344+
(retinanet_b200: 1,170,301) sized for a large system — it is *not* the
345+
count a given submitter should read. ``datasize`` computes the real
346+
per-system minimum from the 5x-memory and 500-step rules
347+
(rules/utils.calculate_training_data_size). When the user has not
348+
explicitly passed ``--params dataset.num_files_train``, resolve it to
349+
that minimum so the run reads exactly the dataset ``datasize``
350+
recommended (and that the ``datasize``-emitted ``datagen`` hint was
351+
told to generate).
352+
353+
Why this matters now: ``skip_listing`` (forced on by
354+
``_apply_skip_listing_params``) makes every rank reconstruct filenames
355+
as ``{prefix}_{idx}_of_{num_files_train}.{ext}`` rather than listing the
356+
directory. If the run's ``num_files_train`` differs from the value the
357+
dataset was generated with, the reconstructed ``_of_{total}`` names
358+
match no object and the startup HEAD checks all miss — exactly the
359+
storage#795 abort, where the run defaulted to the YAML 1,170,301 while
360+
the reporter had generated the (smaller) datasize-recommended set.
361+
362+
Deliberately scoped:
363+
* Never touches datagen. The generation host set (and its memory)
364+
typically differs from the run host set, and submitters routinely
365+
over-generate (up to ~10x) to reuse one dataset across
366+
experiments; generation size is the submitter's choice, threaded
367+
explicitly via the datasize->datagen hint. datagen has no
368+
``cluster_information`` here (dlio.py __init__ skips host-info
369+
collection for datagen), so the guard below no-ops for it.
370+
* Respects an explicit ``--params dataset.num_files_train`` — a
371+
submitter running against a deliberately larger generated set (or
372+
any specific count) sets it themselves and that value wins.
373+
374+
Computes against the same ``cluster_information`` (and the same
375+
declared-vs-measured reconciliation) that ``datasize`` used, so the
376+
resolved value matches the generated dataset's ``_of_{total}`` naming
377+
and satisfies ``check_num_files_train``'s ``configured >= required``
378+
gate by construction.
379+
"""
380+
# An explicit user override always wins — never second-guess it.
381+
if 'dataset.num_files_train' in self.params_dict:
382+
return
383+
384+
cluster_info = getattr(self, 'cluster_information', None)
385+
if not cluster_info:
386+
# No memory basis to size against (datagen path). Leave the YAML
387+
# default in place; generation size is the submitter's call.
388+
return
389+
390+
# Recompute silently: check_num_files_train (during verify_benchmark)
391+
# logs the authoritative "Minimum file count dictated by..." RESULT a
392+
# moment later, so a second copy here — plus the storage#785
393+
# reconciliation warning, which runs never emit today — would just be
394+
# noise. Our own INFO below carries the "why".
395+
import logging as _logging
396+
quiet = _logging.getLogger("mlpstorage_py.num_files_train.resolve")
397+
if not quiet.handlers:
398+
quiet.addHandler(_logging.NullHandler())
399+
quiet.setLevel(_logging.CRITICAL + 1)
400+
quiet.propagate = False
401+
402+
try:
403+
computed, _, _ = calculate_training_data_size(
404+
self.args,
405+
cluster_info,
406+
self.combined_params['dataset'],
407+
self.combined_params['reader'],
408+
quiet,
409+
)
410+
except (ValueError, KeyError) as exc:
411+
self.logger.debug(f'num_files_train auto-resolution skipped: {exc}')
412+
return
413+
414+
dataset_params = self.combined_params.setdefault('dataset', {})
415+
try:
416+
yaml_default = int(dataset_params.get('num_files_train'))
417+
except (TypeError, ValueError):
418+
yaml_default = None
419+
420+
if computed == yaml_default:
421+
# YAML default already equals the minimum — nothing to change.
422+
return
423+
424+
self.params_dict['dataset.num_files_train'] = computed
425+
dataset_params['num_files_train'] = computed
426+
427+
default_note = (
428+
f' (workload YAML default was {yaml_default:,})'
429+
if yaml_default is not None else ''
430+
)
431+
self.logger.info(
432+
f'num_files_train not set; defaulting to the datasize minimum for '
433+
f'this system: {computed:,}{default_note}. The run reads this many '
434+
f'files from --data-dir; if you generated a different count (e.g. '
435+
f'an over-generated dataset), pass --params '
436+
f'dataset.num_files_train=<N>. See storage#795.'
437+
)
438+
339439
@staticmethod
340440
def _strip_uri_scheme(value):
341441
# DLIO obj_store_lib treats storage_root as a bare bucket/prefix and
@@ -697,6 +797,11 @@ def __init__(self, args, **kwargs):
697797
# computed until ``datasize()`` runs. The datasize path calls this
698798
# method itself once the recommendation is known.
699799
if self.args.command != "datasize":
800+
# storage#795: default num_files_train to the datasize-computed
801+
# minimum (not the YAML reference) BEFORE skip_listing derives its
802+
# validation interval, so the interval, the INFO log, the DLIO run,
803+
# and check_num_files_train all agree on the count the run reads.
804+
self._resolve_num_files_train()
700805
self._apply_skip_listing_params()
701806

702807
if self.args.command not in ("datagen", "datasize"):

mlpstorage_py/rules/run_checkers/training.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,12 @@ class TrainingRunRulesChecker(RunRulesChecker):
7676
# entry, so without this line reportgen loading a datasize run's
7777
# metadata marks the run INVALID for a value the tool itself wrote.
7878
# (num_files_train / num_subfolders_train are also tool-written on
79-
# datasize runs but appear in CLOSED_ALLOWED_PARAMS for run commands
80-
# where they are genuine user overrides — leave those to that path.)
79+
# datasize runs, and num_files_train is additionally auto-resolved to
80+
# the datasize minimum on run/configview when the user does not pass it
81+
# explicitly — see _resolve_num_files_train, storage#795. Both cases
82+
# are covered by its CLOSED_ALLOWED_PARAMS entry, which accepts the
83+
# value whether it originated from the user or the tool, so no
84+
# TOOL_INJECTED entry is needed here.)
8185
'dataset.total_disk_bytes',
8286
})
8387

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
Regression test for storage#795 (run-path half).
3+
4+
The workload YAML ``num_files_train`` (retinanet_b200: 1,170,301) is a reference
5+
placeholder sized for a large system — not the count a given submitter should
6+
read. ``datasize`` computes the real per-system minimum from the 5x-memory /
7+
500-step rules. Because ``skip_listing`` is forced on, every rank reconstructs
8+
filenames as ``{prefix}_{idx}_of_{num_files_train}.{ext}`` instead of listing the
9+
directory, so if the run's ``num_files_train`` differs from the value the dataset
10+
was generated with, the reconstructed ``_of_{total}`` names match no object and
11+
the startup HEAD checks all miss. That is the storage#795 abort: the run defaulted
12+
to the YAML 1,170,301 while the reporter had generated the (smaller) datasize set.
13+
14+
``_resolve_num_files_train`` fixes this by defaulting the run/configview
15+
``num_files_train`` to the datasize-computed minimum when the user did not pass
16+
``--params dataset.num_files_train`` — while leaving datagen (different host set,
17+
deliberate over-generation) and explicit user overrides untouched. These tests
18+
exercise the method directly against the same computation ``check_num_files_train``
19+
uses, patched to a known value so the wiring — not the arithmetic — is asserted.
20+
"""
21+
22+
from types import SimpleNamespace
23+
from unittest.mock import MagicMock, patch
24+
25+
from mlpstorage_py.benchmarks.dlio import TrainingBenchmark
26+
27+
_MODULE = "mlpstorage_py.benchmarks.dlio.calculate_training_data_size"
28+
29+
30+
def _make_stub(*, params_dict=None, cluster_information=object(),
31+
combined_num_files_train=1_170_301):
32+
"""Minimal shape ``_resolve_num_files_train`` reads off ``self``."""
33+
return SimpleNamespace(
34+
args=SimpleNamespace(),
35+
params_dict=dict(params_dict or {}),
36+
cluster_information=cluster_information,
37+
combined_params={
38+
'dataset': {'num_files_train': combined_num_files_train},
39+
'reader': {'batch_size': 1},
40+
},
41+
logger=MagicMock(),
42+
)
43+
44+
45+
def test_resolves_to_datasize_minimum_when_not_overridden():
46+
"""The retinanet_b200 reporter scenario: user did not pass num_files_train,
47+
so the run must default to the computed minimum (257,173), not the YAML
48+
reference (1,170,301) — otherwise skip_listing reconstructs ``_of_1170301``
49+
names that miss every generated ``_of_257173`` object."""
50+
stub = _make_stub(combined_num_files_train=1_170_301)
51+
52+
with patch(_MODULE, return_value=(257_173, 0, 83_055_820_561)):
53+
TrainingBenchmark._resolve_num_files_train(stub)
54+
55+
# Both the DLIO override surface and the effective combined config are
56+
# updated so the interval calc, the run, and the run-checker all agree.
57+
assert stub.params_dict['dataset.num_files_train'] == 257_173
58+
assert stub.combined_params['dataset']['num_files_train'] == 257_173
59+
60+
logged = ' '.join(str(c.args[0]) for c in stub.logger.info.call_args_list)
61+
assert '257,173' in logged
62+
assert '1,170,301' in logged # names the YAML default it superseded
63+
assert 'storage#795' in logged
64+
65+
66+
def test_explicit_user_override_is_respected():
67+
"""A submitter running against a deliberately over-generated dataset passes
68+
``--params dataset.num_files_train=N``; that value must win and the method
69+
must not recompute or log."""
70+
stub = _make_stub(
71+
params_dict={'dataset.num_files_train': 2_000_000},
72+
combined_num_files_train=2_000_000,
73+
)
74+
75+
with patch(_MODULE) as calc:
76+
TrainingBenchmark._resolve_num_files_train(stub)
77+
calc.assert_not_called()
78+
79+
assert stub.params_dict['dataset.num_files_train'] == 2_000_000
80+
assert stub.logger.info.call_count == 0
81+
82+
83+
def test_no_cluster_information_is_a_noop():
84+
"""The datagen path has no cluster_information (host-info collection is
85+
skipped for datagen, and its host set / memory typically differ from the
86+
run's anyway). With no memory basis to size against, leave the YAML default
87+
in place and do not touch params_dict."""
88+
stub = _make_stub(cluster_information=None, combined_num_files_train=1_170_301)
89+
90+
with patch(_MODULE) as calc:
91+
TrainingBenchmark._resolve_num_files_train(stub)
92+
calc.assert_not_called()
93+
94+
assert 'dataset.num_files_train' not in stub.params_dict
95+
assert stub.combined_params['dataset']['num_files_train'] == 1_170_301
96+
assert stub.logger.info.call_count == 0
97+
98+
99+
def test_computed_equal_to_yaml_default_does_not_inject_override():
100+
"""When the computed minimum already equals the YAML default, there is
101+
nothing to change — and, crucially, the tool must not manufacture a
102+
redundant override that would show up in the audit trail as a user tune."""
103+
stub = _make_stub(combined_num_files_train=1_170_301)
104+
105+
with patch(_MODULE, return_value=(1_170_301, 0, 1)):
106+
TrainingBenchmark._resolve_num_files_train(stub)
107+
108+
assert 'dataset.num_files_train' not in stub.params_dict
109+
assert stub.combined_params['dataset']['num_files_train'] == 1_170_301
110+
assert stub.logger.info.call_count == 0
111+
112+
113+
def test_compute_failure_degrades_gracefully():
114+
"""If the sizing computation cannot run (missing inputs on some path),
115+
swallow it at debug level and leave the YAML default — never crash the run
116+
over an auto-defaulting convenience."""
117+
stub = _make_stub(combined_num_files_train=1_170_301)
118+
119+
with patch(_MODULE, side_effect=ValueError("no memory basis")):
120+
TrainingBenchmark._resolve_num_files_train(stub)
121+
122+
assert 'dataset.num_files_train' not in stub.params_dict
123+
assert stub.combined_params['dataset']['num_files_train'] == 1_170_301
124+
assert stub.logger.info.call_count == 0
125+
stub.logger.debug.assert_called_once()

0 commit comments

Comments
 (0)