Skip to content

Commit c392b01

Browse files
author
Orbax Authors
committed
Merge pull request #3474 from mridul-sahu:feature/safetensors-auto-over-read-ratio
PiperOrigin-RevId: 948186386
2 parents 549d786 + c144a4f commit c392b01

4 files changed

Lines changed: 212 additions & 90 deletions

File tree

checkpoint/CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
- #v1 Drive the safetensors load by the target sharding: each process reads
1919
only the byte ranges its own devices need (coalesced under
20-
`SafetensorsOptions.max_over_read_ratio`, fetched as concurrent ranged
21-
reads of `SafetensorsOptions.read_chunk_bytes` each, bounded by
20+
`SafetensorsOptions.max_over_read_ratio`, picked per file from the planned
21+
reads by default, fetched as concurrent ranged reads of
22+
`SafetensorsOptions.read_chunk_bytes` each, bounded by
2223
`MemoryOptions.read_concurrent_bytes`), replacing the transient-array
2324
resharding load. Supports arbitrary shardings, including inner-dimension
24-
partitions.
25+
partitions; a warning reports shardings whose fragmented byte layout forces
26+
heavy over-read.
2527

2628
## [0.12.1] - 2026-06-24
2729

checkpoint/orbax/checkpoint/experimental/v1/_src/context/options.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,8 +606,12 @@ class SafetensorsOptions(_ActiveContextGuard):
606606
coalescing per-host byte runs from one file into a single read. A higher
607607
value collapses more requests at the cost of more over-read; a value of
608608
1.0 disables any over-read. Worst-case per-host bytes from one file are
609-
bounded at `max_over_read_ratio * ideal_bytes`. `None` selects an
610-
implementation default (currently `2.0`).
609+
bounded at `max_over_read_ratio * ideal_bytes`. `None` (the default) picks
610+
the ratio per file from the planned reads: no over-read when the target
611+
sharding already maps to few contiguous ranges, whole-span reads when the
612+
plan would otherwise fragment into a very large number of small strided
613+
requests (a warning reports the over-read cost of the sharding in that
614+
case).
611615
read_chunk_bytes: Target size of one ranged read. Coalesced blocks are split
612616
at this size and the pieces are read concurrently, so it trades storage
613617
request count against read parallelism: larger values issue fewer requests

checkpoint/orbax/checkpoint/experimental/v1/_src/layout/safetensors_layout.py

Lines changed: 89 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@
2424
standing in for TensorStore's chunk index.
2525
2626
Byte runs from every tensor a process needs in one file are coalesced under a
27-
single bounded-over-read policy: a `max_over_read_ratio` cap on
28-
`block_size / needed_bytes` keeps cross-host egress from blowing up to
29-
N x file_size on inner-dim sharding. Each coalesced block is then split at a
27+
bounded-over-read policy: a `max_over_read_ratio` cap on
28+
`block_size / needed_bytes`. By default the cap is picked per file from the
29+
planned runs themselves -- no over-read when the sharding already maps to few
30+
contiguous reads, whole-span reads when the plan would otherwise fragment
31+
into a very large number of small strided requests (a warning then reports
32+
the over-read cost of that sharding). Each coalesced block is then split at a
3033
fixed chunk size and the chunks are read concurrently, with total in-flight
3134
bytes bounded by the shared restore budget
3235
(`MemoryOptions.read_concurrent_bytes`). Peak host memory beyond the
@@ -77,13 +80,13 @@
7780
# (`max_over_read_ratio`) and the chunk size (`read_chunk_bytes`); the
7881
# in-flight budget comes from `MemoryOptions.read_concurrent_bytes`.
7982
#
80-
# `_DEFAULT_MAX_OVER_READ_RATIO` caps `block_size / needed_bytes` for any
81-
# coalesced read; a host's bytes pulled from one file are bounded at
82-
# `ratio * ideal_bytes`. The default trades up to 1x over-read for fewer
83-
# requests; the row-parallel inner-dim worst case (which would otherwise
84-
# collapse to "read the whole file on every host") is bounded at 2x and the
85-
# partitioner emits more, smaller reads instead.
86-
_DEFAULT_MAX_OVER_READ_RATIO = 2.0
83+
# With `max_over_read_ratio` unset, each file's ratio is picked from its
84+
# planned runs (`_auto_over_read_ratio`): 1.0 when coalescing at the gap floor
85+
# already leaves at most this many reads -- large contiguous shards gain
86+
# nothing from over-reading -- and the file's `span / needed` otherwise, which
87+
# collapses a fragmented (strided inner-dim) plan into whole-span reads
88+
# instead of an unbounded number of small requests.
89+
_AUTO_MAX_READS_PER_FILE = 1024
8790
# Always coalesce runs separated by less than this gap, regardless of the
8891
# over-read ratio. Below roughly a page the per-read overhead -- a syscall
8992
# locally, an RTT on object storage -- dwarfs the cost of reading the gap, so a
@@ -105,13 +108,12 @@
105108
# streams.
106109
_DEFAULT_READ_CHUNK_BYTES = 128 * 1024 * 1024 # 128 MiB
107110

108-
# When the over-read cap fragments a load into many small reads -- the strided
109-
# inner-dim regime -- hint the user toward `max_over_read_ratio`. The warning
110-
# fires only when reads-per-file exceeds this factor *and* the achieved
111-
# over-read stayed near 1x (the cap blocked coalescing, rather than the load
112-
# genuinely spanning many files).
113-
_FRAGMENTED_READ_WARN_FACTOR = 8
114-
_FRAGMENTED_OVER_READ_CEILING = 1.2
111+
# Heavy over-read under the automatic ratio means the target sharding
112+
# fragments the checkpoint's byte layout and whole spans were read to keep the
113+
# request count bounded. Surface it so the extra bytes are diagnosable: the
114+
# sharding, not the loader, is the thing to change. Over-read from gap-floor
115+
# merges alone stays well below this.
116+
_OVER_READ_WARN_RATIO = 1.5
115117

116118

117119
def _get_dtypes() -> dict[str, Any]:
@@ -378,6 +380,44 @@ def _partition_runs(
378380
return blocks
379381

380382

383+
def _auto_over_read_ratio(runs: Sequence[tuple[int, int]]) -> float:
384+
"""Picks one file's over-read ratio from its planned run geometry.
385+
386+
No single ratio suits every sharding. A process reading a few large
387+
contiguous runs gains nothing from merging across gaps -- that only
388+
wastes bytes. One reading thousands of tiny strided runs is better off
389+
reading the whole span, but the ratio where the plan collapses is the
390+
file's `span / needed`, which shifts with sharding and topology. Values
391+
in between buy more requests *and* more waste, so the choice is binary
392+
and the runs themselves decide it.
393+
394+
Args:
395+
runs: `(offset, length)` byte runs planned for one file; need not be sorted.
396+
Assumed non-overlapping, as `index_domain_to_byte_runs` produces.
397+
398+
Returns:
399+
`1.0` when merging only sub-floor gaps already leaves at most
400+
`_AUTO_MAX_READS_PER_FILE` reads; otherwise the file's whole-span ratio
401+
`span / needed`, which bounds every block and collapses uniform strided
402+
plans into whole-span reads.
403+
"""
404+
if not runs:
405+
return 1.0
406+
ordered = sorted(runs)
407+
needed = 0
408+
num_reads = 1
409+
prev_end = ordered[0][0]
410+
for offset, length in ordered:
411+
needed += length
412+
if offset - prev_end > _DEFAULT_MIN_COALESCE_GAP:
413+
num_reads += 1
414+
prev_end = max(prev_end, offset + length)
415+
if num_reads <= _AUTO_MAX_READS_PER_FILE:
416+
return 1.0
417+
span = prev_end - ordered[0][0]
418+
return max(1.0, span / needed)
419+
420+
381421
def _replicated_sharding() -> jax.sharding.Sharding:
382422
"""A `NamedSharding` that fully replicates an array across all devices."""
383423
mesh = jax.sharding.Mesh(np.asarray(jax.devices()), ("_safetensors_replica",))
@@ -427,7 +467,7 @@ class _ReadStats(NamedTuple):
427467

428468
def _plan_chunk_reads(
429469
reads: Sequence[_Read],
430-
max_over_read_ratio: float,
470+
max_over_read_ratio: float | None,
431471
chunk_bytes: int,
432472
) -> tuple[list[_ChunkRead], _ReadStats]:
433473
"""Plans the ranged reads serving one file's byte runs.
@@ -442,15 +482,17 @@ def _plan_chunk_reads(
442482
Args:
443483
reads: The byte runs to serve, each with its destination buffer.
444484
max_over_read_ratio: Upper bound on `block_size / needed_bytes` per block.
485+
`None` picks the ratio from this file's runs (`_auto_over_read_ratio`).
445486
chunk_bytes: Maximum size of one ranged read. Must be positive and at most
446487
the in-flight byte budget, so any single chunk can be reserved.
447488
448489
Returns:
449490
The chunk reads covering every run, and the file's read accounting.
450491
"""
451-
blocks = _partition_runs(
452-
[(r.offset, r.length) for r in reads], max_over_read_ratio
453-
)
492+
runs = [(r.offset, r.length) for r in reads]
493+
if max_over_read_ratio is None:
494+
max_over_read_ratio = _auto_over_read_ratio(runs)
495+
blocks = _partition_runs(runs, max_over_read_ratio)
454496
chunks: list[_ChunkRead] = []
455497
for block in blocks:
456498
members = block.members # Sorted by run offset (see `_partition_runs`).
@@ -531,7 +573,7 @@ async def _read_file(
531573
path: Path,
532574
reads: Sequence[_Read],
533575
byte_budget: limits.LimitInFlightBytes,
534-
max_over_read_ratio: float,
576+
max_over_read_ratio: float | None,
535577
chunk_bytes: int,
536578
) -> _ReadStats:
537579
"""Serves all of one file's byte runs with concurrent ranged reads.
@@ -541,6 +583,7 @@ async def _read_file(
541583
reads: The byte runs this host needs from the file.
542584
byte_budget: The shared in-flight byte limiter (shared across files).
543585
max_over_read_ratio: Upper bound on `block_size / needed_bytes` per block.
586+
`None` picks the ratio from this file's runs (`_auto_over_read_ratio`).
544587
chunk_bytes: Maximum size of one ranged read.
545588
546589
Returns:
@@ -716,54 +759,45 @@ def _record_read_stats(stats: Sequence[_ReadStats]) -> None:
716759
)
717760

718761

719-
def _warn_if_reads_fragmented(
762+
def _warn_if_over_read(
720763
stats: Sequence[_ReadStats],
721-
num_files: int,
722-
max_over_read_ratio_is_default: bool,
764+
max_over_read_ratio_is_auto: bool,
723765
) -> None:
724-
"""Logs a one-shot hint when the over-read cap fragmented the load.
766+
"""Logs a one-shot hint when the load read far more bytes than it needed.
725767
726-
In the strided inner-dim regime the `max_over_read_ratio` cap rejects merges
727-
and the load degrades into many small ranged reads -- RTT-bound and slow on
728-
object storage. This surfaces that case so a slow load is diagnosable without
729-
profiling. It fires once, only on the primary process, and only when the user
730-
is on the default ratio (someone who set the knob has already weighed the
731-
tradeoff). The signal is a high reads-per-file count together with an achieved
732-
over-read near 1x -- exactly "the cap blocked coalescing" rather than "the
733-
load genuinely spans many files."
768+
Under the automatic per-file ratio, heavy over-read happens only when the
769+
target sharding maps to many small strided runs per process and whole spans
770+
were read instead to keep the request count bounded. The extra bytes are
771+
the cost of that sharding's byte layout, not a loader inefficiency, so the
772+
actionable advice is a sharding whose per-process ranges are contiguous.
773+
Fires once, only on the primary process, and only for the automatic ratio
774+
(someone who set the knob has already weighed the tradeoff).
734775
735776
Args:
736777
stats: Per-file read accounting from each file's reads.
737-
num_files: Number of distinct files the load read from.
738-
max_over_read_ratio_is_default: Whether the user left `max_over_read_ratio`
739-
unset; only then is "raise the ratio" actionable advice.
778+
max_over_read_ratio_is_auto: Whether the user left `max_over_read_ratio`
779+
unset (the per-file automatic choice).
740780
"""
741-
if not max_over_read_ratio_is_default or num_files == 0:
781+
if not max_over_read_ratio_is_auto:
742782
return
743783
if multihost.process_index() != 0:
744784
return
745785
total_needed = sum(s.needed_bytes for s in stats)
746786
total_read = sum(s.read_bytes for s in stats)
747-
total_reads = sum(s.num_reads for s in stats)
748787
if total_needed == 0:
749788
return
750-
achieved_over_read = total_read / total_needed
751-
if (
752-
total_reads <= _FRAGMENTED_READ_WARN_FACTOR * num_files
753-
or achieved_over_read >= _FRAGMENTED_OVER_READ_CEILING
754-
):
789+
over_read = total_read / total_needed
790+
if over_read < _OVER_READ_WARN_RATIO:
755791
return
756792
logging.warning(
757-
"Safetensors load issued %d ranged reads for %s across %d file(s)"
758-
" (achieved over-read %.2fx). The target sharding produces strided reads"
759-
" that the default max_over_read_ratio=%.1f keeps fragmented; raising"
760-
" SafetensorsOptions.max_over_read_ratio trades bounded over-read for far"
761-
" fewer reads (notably faster on object storage).",
762-
total_reads,
793+
"Safetensors load read %s to serve %s of needed bytes (%.1fx"
794+
" over-read). The target sharding needs many small strided ranges per"
795+
" process, so whole spans were read instead to keep the request count"
796+
" bounded. A sharding whose per-process ranges are contiguous (e.g."
797+
" partitioning the leading dimension) avoids the extra bytes.",
798+
humanize.naturalsize(total_read, binary=True),
763799
humanize.naturalsize(total_needed, binary=True),
764-
num_files,
765-
achieved_over_read,
766-
_DEFAULT_MAX_OVER_READ_RATIO,
800+
over_read,
767801
)
768802

769803

@@ -930,11 +964,6 @@ async def _load(
930964
"""Background load phase: plans reads, reads files, assembles arrays."""
931965
context = context_lib.get_context()
932966
opts = context.safetensors_options
933-
max_over_read_ratio = (
934-
opts.max_over_read_ratio
935-
if opts.max_over_read_ratio is not None
936-
else _DEFAULT_MAX_OVER_READ_RATIO
937-
)
938967
# In-flight read bytes reuse the shared restore knob,
939968
# `MemoryOptions.read_concurrent_bytes` (the same limit TensorStore restore
940969
# uses). `None` means "no limit" there, but the chunk sizing here needs a
@@ -1005,7 +1034,7 @@ async def load_file(file_path: Path) -> _ReadStats:
10051034
file_path,
10061035
reads_by_file[file_path],
10071036
byte_budget,
1008-
max_over_read_ratio,
1037+
opts.max_over_read_ratio,
10091038
chunk_bytes,
10101039
)
10111040
# Drop the read plan so each shard's host memory can be released as
@@ -1017,7 +1046,5 @@ async def load_file(file_path: Path) -> _ReadStats:
10171046

10181047
stats = await asyncio.gather(*map(load_file, list(reads_by_file)))
10191048
_record_read_stats(stats)
1020-
_warn_if_reads_fragmented(
1021-
stats, len(builds_by_file), opts.max_over_read_ratio is None
1022-
)
1049+
_warn_if_over_read(stats, opts.max_over_read_ratio is None)
10231050
return {name: arrays[name] for name in names}

0 commit comments

Comments
 (0)