Conversation
3894a6b to
ce3668c
Compare
ce3668c to
0934f0d
Compare
probe_batch_size was written for a measurement that is a single call, and says so. Every training probe needs two things it does not offer, so all three of them -- dfine_node, yolov5_node and classification_node -- reimplement the same composition of reserve_margin, measured_fits and find_batch_size instead. The one caller left is dfine_node's detection pass. Forward on_out_of_memory, which measured_fits already accepts, so a caller can drop what a failed trial left on the card. Add minimum, for a step that cannot run on a single sample at all: BatchNorm over a 1x1 feature map, or a training whose validation halves the batch. The search then runs in units of the minimum, and a failure at that size reports it rather than one. yolov5_node and classification_node had each grown their own copy of that rescaling. A minimum of one searches exactly as before, so nothing that calls this today changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
probe_batch_size leaves each trainer the same handful of lines around it: read the bound, clamp by the dataset, probe. Three nodes spell that three ways, and with it the hyperparameter name, which is a contract with the loop rather than a local choice. measure_batch_size takes it over, so a trainer is left with the one thing only it can supply: the step. One key, not two. batch_size is the largest batch a training may use, and it is measured rather than trusted. A size that does not fit backs off to the largest power of two below it, instead of starting a training that runs out of memory at epoch 30; classification_node returned such a size as given. A size that does fit is used as named even when it is not a power of two, which is what the new candidate is for: it is tried once the doubling has reached its ceiling, so the only non-power-of-two this can return is one somebody asked for and it then measured. A bound derived from the dataset is never a candidate -- samples // 8 is a heuristic, not a size anyone named. minimum and candidate sit in find_batch_size rather than in probe_batch_size, so dfine_node gets both without composing them itself; it reserves its margin before it builds its model and so cannot use probe_batch_size. The settled size is returned, not written back. Writing it into batch_size would leave a second measurement in the same process bounded by the first, which would cost classification_node the whole of its inference gain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three nodes now probe their batch size against this library, and each carries the same wiring around the probe in its own copy. `--vram-limit-gb` stood in three `main.py` files with the same help text, and again in four spawned scripts with another copy of it. The flag name is what `_NodeArgumentParser` derives `VRAM_LIMIT_GB` from, so the environment variable an operator writes into a `.env` was defined by copy-paste. `node_parser` takes it now, opt-in because it means nothing on a detector node, and `add_vram_limit_argument` gives a spawned script the same flag with the same words. `int(hyperparameters.get(BATCH_SIZE, 0) or 0)` stood in two nodes. The `or 0` is load-bearing -- it swallows the `None` and the `''` the loop sends for a field nobody filled in -- and it is exactly the kind of coercion that drifts, so `requested_batch_size` owns it next to the constant it reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trainers report the batch size they settled on as `batch_size`, and the hyperparameters are stored with the training and handed to the next one. With `batch_size` also being the input, a resumed or follow-up training read an earlier card's measurement back as its own upper bound, so a card that once had to settle for 16 capped every later training at 16. The input is now `max_batch_size`, named by REQUESTED_BATCH_SIZE (renamed from BATCH_SIZE, which was not released yet), and `batch_size` stays free for reporting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node_parser(vram_limit=True) and add_vram_limit_argument had no tests: default, env var, flag precedence, the legacy prefix, and the flag being absent without the opt-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md now names the input hyperparameter, why the settled size must not be written back under it, and how a trainer and its spawned script share the VRAM limit. README lists VRAM_LIMIT_GB. The paragraph no longer describes how individual node repositories compose the probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRIBUTING asks for as few and as short comments as possible, stating what is needed to understand the code rather than why it was written that way. Gone: why the help text lives in one place, why the flag is opt-in, why a caller should go through `requested_batch_size`. Kept: what an unfilled hyperparameter looks like, and that a spawned process still has to call `limit_cuda_memory` itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docs said the loop hands a training's hyperparameters to the next one. It does not: it builds each training from the project configuration and the job's override, taking only the resolution from a base training. What does read them back is the node itself, which saves the training to last_training__<uuid>.json and restores it when it resumes after a restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No trainer composes reserve_margin, measured_fits and find_batch_size itself any more: dfine_node now enters through measure_batch_size like the others. Also restore the missing blank line before measure_batch_size (E302). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trainer/cuda.py imported helpers/entrypoint.py only for the flag name and its help text, so the torch side of the probe depended on the node's server boilerplate. The two constants now live in the torch-free batch_size.py, which both node_parser and add_vram_limit_argument read from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
D-FINE's own probe logged the training sample count beside the chosen size; since it enters through measure_batch_size that line was gone, and a size capped by a small dataset looked the same in the log as one capped by memory. measure_batch_size now logs it for every trainer that passes sample_count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8a763cf to
3d16e57
Compare
CONTRIBUTING §4 keeps a comment to what is needed to understand the code, not the motivation behind it. Gone: where REQUESTED_BATCH_SIZE and VRAM_LIMIT_GB_FLAG live and why, why the search uses powers of two, why a minimum above one exists, why the dataset bound is not a candidate, why the vram_limit flag is opt-in, and the comment on why the candidate is reachable. The reason to keep batch_size apart from max_batch_size stays in AGENTS.md and the PR description.
jfrieli
left a comment
There was a problem hiding this comment.
I've added some suggestions, please have a look. Most of them are cheap now but breaking after v0.24.0 is tagged.
Looking forward to move all nodes to the batch size probing. Especially yolo5, where your first tests doubled the estimated batch size!
| """Share of the budget held back while probing, against allocator fragmentation later on.""" | ||
|
|
||
|
|
||
| def measure_batch_size(run_batch: Callable[[int], str | None], *, batch_size: int = 0, |
There was a problem hiding this comment.
This keyword carries the requested upper bound (the max_batch_size hyperparameter), but it is named after the result a trainer reports as batch_size. All three companion PRs end up writing
measure_batch_size(step, batch_size=requested_batch_size(hp))which reads as "use this batch size" and is exactly the input/output mix-up this PR sets out to prevent. Suggest renaming it to max_batch_size:
measure_batch_size(step, max_batch_size=requested_batch_size(hp))including the :param: line and the ValueError message. Since this is not released yet, it only costs one line in each of yolov5_node#65, classification_node#20 and dfine_node#52; after v0.24.0 it would be a breaking change.
| :raises InsufficientMemoryError: If not even ``minimum`` fits. | ||
| """ | ||
| limit = smaller_pot(limit or MAX_BATCH_SIZE) | ||
| bound = max(limit or MAX_BATCH_SIZE, minimum) |
There was a problem hiding this comment.
Please log a warning when the trainer minimum overrides an explicitly requested maximum. For example, measure_batch_size(..., batch_size=1, minimum=2) currently selects 2 silently. Giving the technically required minimum precedence is fine, but the user should see why their setting was exceeded: 'Requested max_batch_size=1 is below the trainer minimum of 2; using 2.' Please also document this precedence in the parameter descriptions, which currently describe the requested value as the largest batch the training may use.
| chosen = find_batch_size(fits, limit=bound, minimum=minimum, candidate=candidate) | ||
| finally: | ||
| del margin | ||
| free_cuda_memory() |
There was a problem hiding this comment.
Suggestion: let measure_batch_size take a factory for the step instead of a step that is already built. It's cheap now, while the interface is unreleased and all three node PRs are adopting it anyway, and it protects every node added or reworked later.
What this finally can't release. It drops the margin, but the step (the throwaway model, its optimizer state, the EMA copy) is still referenced by the caller, so free_cuda_memory() can't reclaim it. That is why every node repeats the same lifecycle around the call:
# classification_node # dfine_node # yolov5_node
finally: finally: finally:
del step del probe step.release()
free_cuda_memory() free_cuda_memory()
self.model.to('cuda')classification and dfine probe in the process that then trains. A node that forgets this finally, or gets it slightly wrong, keeps the throwaway model on the card for the whole training. The training then has less memory than the probe measured and can run out of memory partway through. No test and no log line would show it. Right now avoiding this is up to each node's author; with a factory the library makes it impossible.
What a factory changes
# today (dfine)
probe = _Probe(config_file, dynamic_config)
try:
return measure_batch_size(probe.run_trial, max_batch_size=..., sample_count=probe.sample_count,
on_out_of_memory=probe.drop_gradients, ...)
finally:
del probe
free_cuda_memory()
# with a factory
return measure_batch_size(lambda: _Probe(config_file, dynamic_config), max_batch_size=..., ...)The library then owns the whole order: check for a GPU → reserve the margin → build the step → search → release the step → free memory. As a side effect:
on_out_of_memorymoves from the interface onto the step, because it is knowledge about the step.- The no-GPU fallback becomes reachable. Today every caller has touched CUDA before calling (
torch.cuda.init(),.to('cuda'), or dfine's own guard), so no node ever reaches it. - One library test can assert that nothing references the step after the call, instead of each node having to get it right.
Cost. All three nodes already have a step class (TrainingStep, _TrainingStep, _Probe), so each needs roughly 10–20 lines, mostly moved:
- dfine: pass a lambda, and drop its own no-GPU guard and the
finally. - classification: the
model.cpu()…model.to('cuda')bracket becomes a context manager. - yolov5: pass a lambda.
calclogsstep.img_sizeafter measuring, so that needs computing up front.
The measured sizes should not change, but a short run on n9 per node would confirm it.
To decide
- How the library releases the step: a context manager (
with build() as step, which fits classification's before/after) or an optionalrelease()on the step. - Whether
sample_countstays a parameter (the smaller change for yolov5 and classification) or moves onto the step. probe_batch_sizefor the detection passes keeps taking a plain callable. Nothing is built there, so I think that asymmetry is fine.
While the signature changes anyway. The probe's vram_limit_gb repeats what limit_cuda_memory has already set in the same process: every node calls one with v and then passes the same v to the other. If limit_cuda_memory remembered the budget, the probe could read it from there, and the parameter could leave measure_batch_size and probe_batch_size in the same breaking change.
| def probe_batch_size(run_batch: Callable[[int], str | None], *, probe: str = 'batch-size probe', | ||
| limit: int = 0, vram_limit_gb: float = 0) -> int: | ||
| """Find the largest power-of-two batch size ``run_batch`` fits into. | ||
| limit: int = 0, candidate: int = 0, minimum: int = 1, vram_limit_gb: float = 0, |
There was a problem hiding this comment.
Could probe_batch_size keep only the parameters its own callers use? Its two external callers are the detection passes (classification's inference probe and dfine's _run_detection), and both pass only probe, limit and vram_limit_gb. candidate, minimum and on_out_of_memory are there only so measure_batch_size can forward them.
candidate matters most. Once it is public, a caller has to know from the docstring that an image count or a dataset bound must never be passed as a candidate. Kept inside, that rule is enforced by construction: only measure_batch_size can name a candidate, and it only ever names the requested size.
Suggestion: move the full-parameter version into a private core that both call, and keep the public probe_batch_size(run_batch, *, probe, limit, vram_limit_gb). The duplicated minimum tests in test_cuda.py could then collapse onto find_batch_size and measure_batch_size.
Same timing as the other interface comments: all three parameters are new in this PR, so narrowing it now breaks nothing. After v0.24.0 it would.
| limit = smaller_pot(limit) | ||
| if not fits(1): | ||
| raise InsufficientMemoryError('batch size 1 does not fit in memory') | ||
| minimum = smaller_pot(max(1, minimum)) |
There was a problem hiding this comment.
This rounds minimum down, so the search can start below the lower bound the caller named: minimum=3 tries 2 first, 6 tries 4, 12 tries 8.
minimum is documented as the size below which the step cannot run at all, so the trial below it doesn't run out of memory: it fails some other way, e.g. BatchNorm's Expected more than 1 value per channel. measured_fits correctly treats that as not-an-OOM and re-raises it, so the probe aborts with what looks like a bug in the node's step, although the library was the one that went below the minimum.
Nobody hits this today, because all three nodes use MIN_BATCH_SIZE = 2, which is already a power of two. It is a trap for the first node that needs 3 or 6, e.g. a training split across 3 GPUs that needs at least 2 samples per GPU.
Suggestion: round up to the next power of two, so the smallest trial is never below minimum. The same applies to the no-GPU fallback in probe_batch_size (cuda.py:101). test_a_minimum_is_rounded_down_to_a_power_of_two pins the current behaviour in both test_batch_size.py and test_cuda.py, and the :param minimum: text says "rounded down". Since this changes how a new public parameter behaves, now is the cheap moment, before v0.24.0.
Motivation
Every trainer that probes its batch size repeats the same code around the step: claim a safety margin, search, tell an out-of-memory failure from a bug, read the requested bound from the hyperparameters, and declare a
--vram-limit-gbflag for itself and for the process it spawns. This PR moves all of it into the library, so a trainer only supplies the step it wants measured.Implementation
Probing
probe_batch_sizewithon_out_of_memory(drop what a failed trial left on the card),minimum(for steps that cannot run on a single sample, e.g. BatchNorm over a 1×1 feature map) andcandidate(an exact, non-power-of-two size that was asked for and then measured)measure_batch_size, the whole decision: honour the requested bound, bound the search bydataset_limit(sample_count), and return the settled size without writing it back anywhereThe requested bound
max_batch_sizehyperparameter, named byREQUESTED_BATCH_SIZEand read throughrequested_batch_size(absent,None,''and0all mean "the card decides")batch_sizefree for reporting the result. The node saves the hyperparameters with a training (LastTrainingIO) and a training resumed after a restart reads them back, so reading the input from the same key would make it take its first run's measurement as its bound. The loop itself never hands reported values to a later trainingVRAM limit
node_parser(vram_limit=True), which declares--vram-limit-gb/VRAM_LIMIT_GBwith one shared help text (VRAM_LIMIT_GB_FLAG,VRAM_LIMIT_GB_HELP)add_vram_limit_argumentfor the script a trainer spawns. The cap does not survive a spawn, so that script declares the same flag and callslimit_cuda_memoryitselfDocs and tests
AGENTS.mdand listVRAM_LIMIT_GBin the READMECompatibility
All new parameters are keyword-only with defaults that keep 0.23.0 behaviour. New public names:
measure_batch_size,requested_batch_size,REQUESTED_BATCH_SIZE,add_vram_limit_argument,VRAM_LIMIT_GB_FLAG,VRAM_LIMIT_GB_HELP, and thevram_limitparameter ofnode_parser.The node repositories pin
learning_loop_node==0.24.0on their companion branches, so this needs av0.24.0release (the version comes from the tag).Checks
python -m pytest learning_loop_node/tests/unit: 276 passed.uvx ruff checkis clean on every touched file. The unit suite fakes torch; what a real step costs can only be measured on a GPU in the nodes.⬛ claude-opus-5-5