Skip to content

Fit planner: deterministic per-file chunk budgets (device_memory_budget) - #91

Merged
takeshi-yoshimura merged 2 commits into
foundation-model-stack:mainfrom
gitbisector:pr-b-fit-planner
Aug 13, 2026
Merged

Fit planner: deterministic per-file chunk budgets (device_memory_budget)#91
takeshi-yoshimura merged 2 commits into
foundation-model-stack:mainfrom
gitbisector:pr-b-fit-planner

Conversation

@gitbisector

@gitbisector gitbisector commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

A fixed max_batch_bytes budget (#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-time BudgetInfeasibleError naming 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:

free, _ = torch.cuda.mem_get_info(dev)
budget = free - max(free // 20, 1 << 30)   # reserve for allocator rounding + fixed pools
# multi-rank: all-reduce(MIN) budget before passing it, so every rank plans identically

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_multiplier defaults to raising NotImplementedError (same contract as set_chunk), UnifiedMemCopier mirrors its own reader-path selection, and NoGdsFileCopier returns 1. parallel_loader reaches 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.

@takeshi-yoshimura

Copy link
Copy Markdown
Collaborator

@gitbisector
Thanks for contributing this. I understand this is important especially for unified memory systems like DGX Spark to utilize more memory while avoiding OOM. Can you rebase recent changes and make this ready for review?

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, parallel_loader.py should not depend on a specific copier module. Specifically, we need to remove the line from .copier.unified import chunk_transient_multiplier. CopierInterface should provide chunk_transient_multiplier and raise NotImplementedError in other copier backends like set_chunk.

…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>
gitbisector added a commit to gitbisector/fastsafetensors that referenced this pull request Aug 11, 2026
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>
@gitbisector
gitbisector marked this pull request as ready for review August 11, 2026 03:37
@gitbisector

Copy link
Copy Markdown
Contributor Author

Thanks — rebased onto current main (#90 having merged, this is now a single feature commit plus the review response), and both points addressed:

  1. Caller-side budgeting. Agreed — the caller knows what else lives on the device and what the other ranks hold. device_memory_budget is now an int only: "auto", resolve_auto_budget, the FrameworkOpBase.get_mem_free op and its torch implementation, and the single-group guard that "auto" required are all gone (an explicit budget is already identical on every rank, so the plan stays deterministic without it). The reserve heuristic and the all-reduce(MIN) recipe moved to the _planner module docstring for callers to lift. frameworks/ is back to byte-identical with main.

  2. No copier-specific import in parallel_loader. chunk_transient_multiplier is now on CopierInterface with a default that raises NotImplementedError, the same contract as set_chunk — a copier that stages a chunk more than once has to say so, or the plan under-counts and OOMs. UnifiedMemCopier keeps mirroring its own reader-path selection (1× on O_DIRECT, 2× on the mmap+pin fallback); NoGdsFileCopier returns 1, since its bounce-buffer pool is fixed-size and 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 parallel_loader imports no copier module at all — and gds/3fs now fail at plan time with a clear message instead of silently borrowing the unified copier's number.

Verified on a DGX Spark (GB10, CUDA 13.1, torch 2.10): tests/unit is 184 passed on this branch vs 164 on main, plus an end-to-end check on cuda:0 confirming a device_memory_budget load is byte-identical to a plain load, the multiplier resolves per copier and per reader path, and an infeasible budget is rejected at plan time naming the shard.

(Unrelated: tests/unit/threefs segfaults in the mock reader for me, but it reproduces identically on unmodified main — happy to open a separate issue if it isn't already known.)

gitbisector added a commit to gitbisector/fastsafetensors that referenced this pull request Aug 11, 2026
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>
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>
@takeshi-yoshimura
takeshi-yoshimura merged commit 3b1cff3 into foundation-model-stack:main Aug 13, 2026
13 checks passed
@takeshi-yoshimura

Copy link
Copy Markdown
Collaborator

Merged. thanks! I have quickly opened the issue #99, but I believe that is low priority as of now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants