Fit planner: deterministic per-file chunk budgets (device_memory_budget) - #91
Conversation
7004e7a to
60984d9
Compare
60984d9 to
05b9284
Compare
|
@gitbisector I would suggest that the auto budgeting could be implemented in the caller side rather than here because callers should have more knowledge of ranks' memory usages. Also, |
…budgets device_memory_budget (int bytes | "auto") bounds resident tensors + transient buffers at peak, per rank. A static fit plan -- precomputed from safetensors headers plus one free-memory query -- gives every file the largest budget the bound allows: whole-file loads while headroom is ample (the existing fast path, zero chunking overhead), per-file budgets declining as cumulative resident bytes grow, chunking only where the fit requires it, and a plan-time BudgetInfeasibleError naming the file and required bytes when the model cannot fit. Deterministic: same files, filter, budget -> same plan. - fit planner (pure, folded into the internal _planner module): FileWeightStats, pipeline_depth, collect_file_stats, plan_file_budgets, resolve_auto_budget (reserve = max(5% free, 1 GiB)); bound lemma in the module docstring. - accumulate_resident flag: True = consumer keeps yielded tensors; False = destinations preallocated -> uniform budget/depth. - frameworks: get_mem_free(dev) op (torch: cuda.mem_get_info / sysconf). - parallel_loader: per-file budgets feed the existing chunk-batch machinery; header reads done once and reused for planning and chunk expansion. - config: device_memory_budget field; forward both kwargs regardless of use_pipeline. Explicit integer budgets work with broadcast loading: the same plan lands on every rank (header reads are deterministic); one extra unit of pipeline depth accounts for the in-flight broadcast receive tensor. "auto" stays single-group only: per-rank free-memory readings diverge and would deadlock the lockstep broadcast -- callers owning a process group should all-reduce(MIN) free memory and pass the result. Transient cost is path-dependent (measured on GB10 unified memory, 1-2 GiB chunks): the O_DIRECT reader costs ~1x span per live chunk plus a fixed ~150 MB thread pool (absorbed by the auto reserve), but the mmap+pin_memory fallback additionally pins the chunk's pages until wait_io -- on unified memory both draws share one physical pool, so each live chunk costs ~2x span. plan_file_budgets takes transient_multiplier; chunk_transient_multiplier() in the unified copier mirrors submit_io's path selection and the parallel loader wires it in. - tests: planner math, infeasibility, auto reserve, multiplier (halving, 2x infeasibility, validation), randomized replay asserting peak <= budget, and a CPU end-to-end budgeted load byte-identical to a plain load. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: git bisector <gitbisector@gmail.com>
Addresses the two points raised on foundation-model-stack#91. 1. device_memory_budget is now an int only. The caller knows what else lives on the device and, across ranks, what every other rank has -- so it picks the number. Drops "auto", resolve_auto_budget, the FrameworkOpBase.get_mem_free op and its torch implementation, and the single-group guard that "auto" needed (an explicit budget is already identical on every rank, so the plan stays deterministic). The reserve heuristic and the all-reduce(MIN) recipe move to the planner module docstring, where callers can lift them. 2. parallel_loader no longer imports a specific copier module. chunk_transient_multiplier moves onto CopierInterface with a default that raises NotImplementedError -- same contract as set_chunk, since a copier that stages a chunk twice must say so or the plan under-counts and OOMs. UnifiedMemCopier keeps the reader-path mirroring (1x on O_DIRECT, 2x on the mmap+pin fallback); NoGdsFileCopier returns 1 (its bounce-buffer pool is fixed-size, so only the chunk buffer scales). The planner reaches it through the loader's copier class, registered alongside each factory (register_copier_constructor(type, cls) / get_copier_class), so gds and 3fs now fail at plan time with a clear message instead of borrowing the unified copier's number. Signed-off-by: git bisector <gitbisector@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
05b9284 to
950222a
Compare
|
Thanks — rebased onto current
Verified on a DGX Spark (GB10, CUDA 13.1, torch 2.10): (Unrelated: |
Addresses the two points raised on foundation-model-stack#91, plus fallout found while testing them. 1. device_memory_budget is now an int only. The caller knows what else lives on the device and, across ranks, what every other rank has -- so it picks the number. Drops "auto", resolve_auto_budget, the FrameworkOpBase.get_mem_free op and its torch implementation, and the single-group guard that "auto" needed (an explicit budget is already identical on every rank, so the plan stays deterministic). The reserve heuristic and the all-reduce(MIN) recipe move to the planner module docstring, where callers can lift them. 2. parallel_loader no longer imports a specific copier module. chunk_transient_multiplier moves onto CopierInterface with a default that raises NotImplementedError -- same contract as set_chunk, since a copier that stages a chunk twice must say so or the plan under-counts and OOMs. UnifiedMemCopier keeps the reader-path mirroring (1x on O_DIRECT, 2x on the mmap+pin fallback); NoGdsFileCopier returns 1 (its bounce-buffer pool is fixed-size, so only the chunk buffer scales). 3. Resolve that class from the constructor, not the type name. Asking for "gds" on a host without cuFile -- any GPU box without GDS, i.e. the default nogds=False path -- hands back a nogds/unified constructor, so keying off copier_type made device_memory_budget fail with "GdsFileCopier does not implement sub-file chunking" while max_batch_bytes kept working on the same loader. Factories now tag the constructor they return with the copier that will really be built (innermost tag wins, so a delegating factory reports its delegate). 4. Charge resident per batch group. Under broadcast the pg.size() files of a group load together and every rank ends up holding all of them, so charging each file only its own prefix left the rest of its group unbudgeted and the peak <= budget bound could be exceeded on uneven shards. plan_file_budgets takes group_size; with group_size == 1 the arithmetic is unchanged. Tests: the property test now drives the planner through the real pipeline_depth while replaying against an independently written depth model, so a wrong depth no longer cancels out on both sides; its corpus covers multi-rank groups, transient multipliers and budgets tight enough to be refused. Mutation-checked: dropping the multiplier, the feasibility floor, the depth term, the group-resident rule, nogds's multiplier override, or nogds's class registration each fail at least one test -- every one of those passed silently before. Removed three tests that could not fail (a self-fulfilling reader-path assertion, a rank-independence check on a value the planner never reads, and a restatement of integer division). Signed-off-by: git bisector <gitbisector@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
950222a to
5b1027c
Compare
Addresses the two points raised on foundation-model-stack#91, plus fallout found while testing them. 1. device_memory_budget is now an int only. The caller knows what else lives on the device and, across ranks, what every other rank has -- so it picks the number. Drops "auto", resolve_auto_budget, the FrameworkOpBase.get_mem_free op and its torch implementation, and the single-group guard that "auto" needed (an explicit budget is already identical on every rank, so the plan stays deterministic). The reserve heuristic and the all-reduce(MIN) recipe move to the planner module docstring, where callers can lift them. 2. parallel_loader no longer imports a specific copier module. chunk_transient_multiplier moves onto CopierInterface with a default that raises NotImplementedError -- same contract as set_chunk, since a copier that stages a chunk twice must say so or the plan under-counts and OOMs. UnifiedMemCopier keeps the reader-path mirroring (1x on O_DIRECT, 2x on the mmap+pin fallback); NoGdsFileCopier returns 1 (its bounce-buffer pool is fixed-size, so only the chunk buffer scales). 3. Resolve that class from the constructor, not the type name. Asking for "gds" on a host without cuFile -- any GPU box without GDS, i.e. the default nogds=False path -- hands back a nogds/unified constructor, so keying off copier_type made device_memory_budget fail with "GdsFileCopier does not implement sub-file chunking" while max_batch_bytes kept working on the same loader. Factories now tag the constructor they return with the copier that will really be built (innermost tag wins, so a delegating factory reports its delegate). 4. Charge resident per batch group. Under broadcast the pg.size() files of a group load together and every rank ends up holding all of them, so charging each file only its own prefix left the rest of its group unbudgeted and the peak <= budget bound could be exceeded on uneven shards. plan_file_budgets takes group_size; with group_size == 1 the arithmetic is unchanged. Tests: the property test now drives the planner through the real pipeline_depth while replaying against an independently written depth model, so a wrong depth no longer cancels out on both sides; its corpus covers multi-rank groups, transient multipliers and budgets tight enough to be refused. Mutation-checked: dropping the multiplier, the feasibility floor, the depth term, the group-resident rule, nogds's multiplier override, or nogds's class registration each fail at least one test -- every one of those passed silently before. Removed three tests that could not fail (a self-fulfilling reader-path assertion, a rank-independence check on a value the planner never reads, and a restatement of integer division). Signed-off-by: git bisector <gitbisector@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5b1027c to
9295032
Compare
3b1cff3
into
foundation-model-stack:main
|
Merged. thanks! I have quickly opened the issue #99, but I believe that is low priority as of now. |
A fixed
max_batch_bytesbudget (#90) pays chunking cost on every shard even while device memory is still empty. The fit planner precomputes per-file budgets from the safetensors headers: whole-file loads while headroom is ample, budgets declining as resident bytes grow, chunking only the tail shards — and a plan-timeBudgetInfeasibleErrornaming the file and required bytes, instead of an OOM mid-load.Deliberately deterministic — same files, filter, and budget produce the same plan; no runtime feedback — staying out of the adaptive-tuning territory flagged in #71. The budget is an explicit integer, which makes it broadcast-safe: the same plan lands on every rank, with one extra depth unit for the in-flight receive tensor.
Choosing the budget is the caller's job, per review. The caller knows what else lives on the device and what every other rank has; the planner module documents the reserve heuristic and the all-reduce(MIN) recipe for multi-rank callers to lift:
Transient cost is path-dependent (measured on GB10 unified memory, 1–2 GiB chunks): the O_DIRECT reader costs ~1× span per live chunk plus a fixed ~150 MB thread pool, but the mmap+pin fallback also pins the chunk's pages until the copy completes — on unified memory both draw from one physical pool, so those chunks cost ~2× span. The planner charges accordingly via
transient_multiplier, which each copier reports for itself:CopierInterface.chunk_transient_multiplierdefaults to raisingNotImplementedError(same contract asset_chunk),UnifiedMemCopiermirrors its own reader-path selection, andNoGdsFileCopierreturns 1.parallel_loaderreaches it through the loader's registered copier class, so it depends on no specific copier module.Measured: on a 46-shard / 160 GB checkpoint, the planner matched full-speed loading at every feasible budget.