unsloth/tests/saving
Daniel Han 48ea5560ee
Use Kaggle's large overlay for saves, and refuse a GGUF export that cannot fit (#8439)
* Refuse a GGUF export that cannot fit, and use Kaggle's large /tmp overlay

Two disk failures seen in real notebook runs, fixed in front of the writes
rather than after them.

Kaggle exhausts /kaggle/working, which is about 20GB, while the overlay
mounted at /tmp on the same kernel has over a terabyte free. A merge or GGUF
export pointed at a relative path under the working directory that cannot fit
there now goes to /tmp instead, with one printed line saying where the files
are and that /tmp is scratch space Kaggle does not save as kernel output. An
absolute path is never moved, because silently relocating a directory the
caller named would be worse than the disk error it avoids, and a push_to_hub
save_directory is a repo id rather than a path so it is left alone entirely.

Gemma4 26B A4B Vision, Gemma4 31B Vision and Qwen3 32B each trained, ran
inference and wrote a complete merged_16bit, then died partway through a GGUF
shard. The size in front of them counted the model twice; the real peak is the
pre-warmed base in the Hugging Face cache, the merge, the intermediate GGUF
and the quants, all on one filesystem at once. _preflight_gguf_disk sizes all
four before the merge starts. When the export fits but a cached base as well
does not, it drops the pre-warm rather than refusing, because that is an
optimization for the next export and this one still runs. When nothing fits it
raises with the two numbers and what to do about them.

Neither guard blocks on a guess: an unmeasurable model or an unmeasurable
filesystem proceeds exactly as before, as does UNSLOTH_DISK_PREFLIGHT=0.
Kaggle detection moves to unsloth_zoo.disk_utils, with a fallback for an
older installed unsloth_zoo that keeps the environment question answered
correctly and turns the new guard into a no-op.

30 new tests, each watched to fail against a perturbed fix.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the Kaggle merge preflight measurable, and size what the export really writes

The merge preflight passed keep_intermediate_gguf to
unsloth_zoo.disk_utils.estimate_gguf_export_bytes, which has no such
parameter, so every call raised TypeError into the surrounding except and
no save_pretrained_merged was ever redirected on Kaggle. A merge writes no
GGUF at all, so it is now sized with model_16bit_bytes rather than the GGUF
export estimate, which always prices an intermediate conversion.

Also, on the same path:
- every compressed export (fp8, nvfp4, mxfp8, w4a16, ...) keeps the 16-bit
  merge and writes a quantized sibling, so all of them are measured, and the
  sibling is counted, not just mxfp4's merge;
- save_method spellings are normalized the way unsloth_save_model normalizes
  them, so "merged 16bit" is measured like merged_16bit;
- the non-PEFT fallback save_pretrained writes a full checkpoint, so the GGUF
  estimate counts it instead of assuming a non-PEFT model has one on disk;
- imatrix_file=False disables the imatrix in _resolve_imatrix_file, so the
  preflight no longer sizes a two-pass conversion for it;
- Kaggle and Colab never pre-warm the hub cache, so the redirect decision
  stops pricing a cache copy that cannot exist there and sending an export
  that fits in /kaggle/working to /tmp, which is not kept as output.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Size the Kaggle merge redirect the way the merge itself measures it

The redirect asked for the raw tensor bytes, but merge_and_overwrite_lora
compares the save against int(free * 0.95), so a merge that only just fit was
left in /kaggle/working and then refused outright instead of moving to /tmp.
Ask for the same effective figure.

Also size a quantized sibling by what a weight-only scheme actually shrinks:
compressed-tensors and torchao quantize Linear weights only, so the
embeddings and an untied lm_head stay 16-bit. The torchao portable exports
now take the redirect too; their 16-bit merge is staged in a temp directory,
so only the sibling is priced at save_directory.

And the non-PEFT GGUF fallback save_pretrained()s the model at its own dtype,
which for a float32 load is twice what the estimate budgeted.

* Do not redirect a torchao export onto a /tmp that its staging merge also fills

The portable torchao exports now reach the Kaggle redirect, but the sibling is
the only artifact the redirect sizes. _unsloth_save_torchao merges into
tempfile.mkdtemp() and keeps that 16-bit staging checkpoint until quantization
finishes, and on a Kaggle kernel tempfile resolves to the same /tmp the
redirect moves the sibling to, so both are on the destination at once.

A working directory with room for the sibling but not the 5% the merge guard
reserves therefore had its export moved to a /tmp that then ran out, when
leaving it in place would have worked.

The staging bytes are checked against the destination rather than added to
need_bytes: nothing stages in the working directory, so charging it there
would relocate exports that fit into /tmp, which is not kept as notebook
output. The check can only cancel a redirect, never cause one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stat the real redirect destination in the torchao staging test

The staging guard's own tests stub _same_filesystem, so nothing exercised the
os.stat pair it is built on, and nothing recorded why stat-ing the destination
is safe: kaggle_tmp_redirect returns a message only after os.makedirs has
succeeded, and the fallback in unsloth/disk_utils.py never returns one, so the
guard is unreachable with a destination that does not exist.

Two tests. The first cancels a redirect with _same_filesystem left unstubbed
and a real directory created under the tempfile default. Cancelling is the one
outcome the helper cannot reach by accident, since every failure inside it,
os.stat included, returns True and takes the redirect, so a real destination
that is really rejected proves both stats resolved. The second pins the
invariant the first depends on, against whichever kaggle_tmp_redirect is
installed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Preflight a full model saved as LoRA, and measure the GGUF sibling's disk

Two gaps in the Kaggle disk guard, both of which let an export pass a check
that measured the wrong thing.

A model with no adapter saved with save_method="lora" is not a LoRA save:
unsloth_generic_save and unsloth_save_model both fall back to save_pretrained
and write the entire model, so a full fine-tune asked for "lora" fills
/kaggle/working exactly like a merge. The preflight skipped it on the method
name alone. It is now sized whenever the model is not a PeftModel, from the
parameters' own storage rather than as a 16-bit merge, because that fallback
casts nothing: an fp32 model writes four bytes per parameter and a 4-bit one
its packed storage. A real PeftModel still writes adapters and is still
skipped, and a model that cannot be measured is left exactly as before.

The GGUF files land in save_directory + "_gguf", which is a sibling, so they
sit on the parent's filesystem. That is the same disk as save_directory unless
that path is itself a mount point or a symlink onto another one, and then the
aggregate estimate was checked against a filesystem the quants never touch.
The sibling is now probed separately and compared against the export minus the
checkpoint, which is what actually goes there. Only a strictly tighter sibling
can change the outcome, so a single filesystem behaves exactly as before, an
unmeasurable path leaves the decision alone, and an estimator that cannot
answer the new question leaves the main guard standing. The export and the
preflight now share one definition of that directory so the two cannot drift.

* Charge the compressed sibling for every module the recipe refuses to quantize

The compressed recipe ignores more than the embeddings: `lm_head`,
`re:.*\.linear_attn\..*`, `re:.*\.visual\..*`, `re:.*mtp.*`, and on an MoE
`re:.*\.gate$` and `re:.*\.shared_expert_gate$`. The sizing helper walked only
`get_input_embeddings` and `get_output_embeddings`, so a VLM's vision tower, a
Qwen3-Next hybrid's linear attention, an MTP head and the MoE routers were all
priced at 4 or 8 bits for bytes the export copies across at 16. That
under-counts `need`, and an under-counted `need` is how a merge that should
have gone to /tmp stays in /kaggle/working.

The four lines that build `ignore` are now a module-level
`compressed_ignore_patterns(config)` in `_compressed_quantize.py`, which
`main()` calls and which `save.py` reads, so the sizing cannot drift from the
recipe again. That module's imports stay stdlib only.

`_unquantized_parameter_bytes` takes the pattern list and adds the 16-bit
bytes of every module the recipe ignores, matching compressed-tensors'
`match_name`: a `re:` prefix is `re.match` against the fully qualified module
name, a plain entry is an exact name or a parent class name. Ids are
deduplicated against the embeddings, so an `lm_head` that is also
`get_output_embeddings()` is counted once and a nested match under an already
counted tower is not counted twice.

The torchao branch passes no patterns. `_unsloth_save_torchao` quantizes with
a bare weight-only config and has no ignore list, so charging it these would
over-count, and over-counting relocates an export that fits into a /tmp that
Kaggle does not keep as notebook output.

Every new path degrades to the old estimate rather than raising: a missing or
renamed symbol, a model whose modules cannot be walked, a module that will not
answer `parameters()`, or an unparseable pattern.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Charge each filesystem only for what the export writes to it

The GGUF preflight measured the sibling directory's filesystem but still
compared the aggregate estimate against the checkpoint's, so a save
directory that is a mount point or a symlink onto another mount had the
intermediate conversion and every quant charged to a disk they never
touch. A checkpoint that fits, with a sibling filesystem that has ample
room for the quants, was refused.

Both halves now hang off one predicate computed once, so they cannot
disagree about whether the storage is split. When it is split the
checkpoint portion, need minus need_sibling, is charged to the save
directory's free space and need_sibling to the sibling's; when it is not,
nothing changes. The predicate is the device id of the filesystem each
probe measures, rather than the sibling reporting less free space, because
two disk_usage calls on one filesystem can disagree when something else
writes between them and reading that as two filesystems would charge a
single-filesystem export the larger of its two halves instead of their
sum. An unreadable or zero device id means not split.

The sibling refusal no longer requires the sibling to be the tighter of
the two: once the checkpoint is charged only its own portion, a sibling
that is roomier than the save directory and still short of need_sibling
has to be caught there, because the aggregate comparison that used to
catch it is gone.

The new branch can only turn a refusal into a pass or change which message
is raised, and it runs after the redirect has been decided, so it still
cannot cause one. An estimator that cannot size the sibling leaves
need_sibling at zero, which makes the checkpoint portion the whole
estimate again.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Charge the GGUF export for what is on disk at once, and where

Three corrections to the disk preflight, all of them cases where the figure it
compares against free space is not the figure the export produces.

A disposable merge is not charged for all three artefacts at once.
`_free_merge_if_disk_is_tight` deletes this export's own merge once the
intermediate GGUF exists and before the first quantize pass, so the peak is the
larger of "merge plus intermediate" and "intermediate plus quants", not their
sum. Nemotron-3-Nano-30B-A3B on a 132GB disk peaks at 128GB and runs; the
aggregate 147GB refused it. `_preflight_gguf_disk` takes the same
`merge_is_disposable` flag the reclamation acts on, and relaxes only when a
quantize pass follows, only on one filesystem (the reclamation declines across
two), only when the output directory holds no weights this export did not
write, and only when the peak is lower than the aggregate.

The working directory the initial conversion writes to is measured.
`convert_to_gguf` passes a bare `--outfile`, which llama.cpp resolves against
the process CWD, and the finished file is moved into the `_gguf` directory
afterwards. So a Kaggle export redirected to /tmp still writes its largest
staging artefact into the 20GB working directory, on a filesystem nothing here
looked at. When that directory is a different filesystem from where the file
ends up, it now has to hold the intermediate, and the export is refused with
the directory named if it cannot.

Ignored modules are sized from their logical shapes. `model_16bit_bytes` sizes
the merge through unsloth_zoo's `logical_numel`, which reads
`quant_state.shape`; `_unquantized_parameter_bytes` was subtracting a figure
built from `numel()`, which on a 4-bit `Params4bit` is the packed uint8 count
and roughly half. A Qwen3-Next linear-attention subtree was priced at 8 bits
for bytes the export writes at 16, and an under-counted need is a Kaggle
redirect that never happens. It now calls `logical_numel` itself, through
`named_parameters` so the name reaches it, which is the only way MXFP4 packing
is identifiable.

Every new path degrades to the previous behaviour rather than raising: an
unreadable working directory, an unmeasurable free figure, an estimator that
cannot size a phase, a directory that cannot be listed, a module that cannot
name its parameters.

* Keep the merge guard's reserve when the estimate is split

`_preflight_gguf_disk` charges the checkpoint's own portion to the filesystem
holding `save_directory` once the GGUF sibling is on another one. That portion
is two bytes per parameter exactly, and `merge_and_overwrite_lora` refuses to
write a merge unless `free * 0.95` covers it, so 16GB of checkpoint on 16GB of
disk passed the preflight and died in the merge seconds later. The aggregate
branch never needed the reserve because it charges the quants as well.

Clamped at `need`, so the split still cannot refuse an export the aggregate
allowed, and skipped where no merge is written or where the sibling could not
be sized and the figure is a fallback rather than a checkpoint.

Also size a full-model `"lora"` save from the caller's `state_dict` when there
is one. `save_pretrained` writes that dict, only `"16bit" in save_method`
casts it, and both `_preflight_merge_disk` call sites accept one and document
`"lora"`, so an fp32 dict over fp16 parameters was priced at half.

* Say which disk ran out when the staging merge is on its own filesystem

`_destination_holds_torchao_staging` asks whether the redirect DESTINATION can
hold the staging merge as well, which is the whole question on Kaggle, where
`tempfile` and the destination are both /tmp. When they are separate mounts it
returns True and nothing has measured the staging filesystem at all, so a 4GB
tmpfs is handed a 60GB merge and `_unsloth_save_torchao` dies inside
`tempfile.mkdtemp` without naming TMPDIR.

A warning rather than a refusal or a cancelled redirect: the preflight never
raises, and the staging merge is written to TMPDIR whether or not the export
was relocated, so declining the move leaves the identical failure and puts the
output on the smaller disk too.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Compare filesystems by the ancestor a fresh destination writes to

`_same_filesystem` stat-ed both paths directly, and `_preflight_merge_disk` runs
before anything has been created, so on a first torchao export the destination
was a name and not a directory. The stat raised FileNotFoundError, the helper's
broad handler swallowed the whole probe, and an undersized TMPDIR went
unmentioned in exactly the case the warning was added for.

It now resolves the nearest existing ancestor, which is the filesystem the write
really lands on and the same resolution `free_bytes` and `_filesystem_id`
already use. A path neither side can identify still raises, because both callers
read that as "cannot tell" and cancel the probe rather than guess.

* Reserve in the TMPDIR warning what the staging merge really needs

The warning compared the free space on a separate TMPDIR against the raw size
of the staging merge, but that merge is written by `merge_and_overwrite_lora`,
which refuses to write anything `int(free * 0.95)` does not cover. Between the
merge's size and that size over 0.95 the export therefore died with "Failed
saving - no disk space left" while the diagnostic that exists to name the disk
stayed silent, and on a separate TMPDIR the merge's own temp-folder fallback
lands on the same filesystem, so nothing recovers it.

Compared against the reserved figure now, the same one `_preflight_merge_disk`
asks the redirect for, and the printed size is that figure so the number and
the threshold agree.

* Charge the merge guard's reserve only where that guard runs

The split branch added `merge_and_overwrite_lora`'s 5% reserve to the checkpoint
whenever `needs_merge` was set, but `needs_merge` is also true for a non-PEFT
model with no reusable local `_name_or_path`: the GGUF path has to write a
checkpoint there too, and it writes it with a bare `self.save_pretrained`, which
consults no guard and reserves nothing. A filesystem holding exactly that
checkpoint was refused for headroom the writer never asks for.

Gated on the model really being a PEFT one, which is the only case that reaches
the merge. The tests that cover the reserve now use a model the preflight
recognises as PEFT, so they exercise the path the reserve belongs to.

* Follow the reused checkpoint when an unwritable CWD moves the conversion

`convert_to_gguf` passes a bare `--outfile`, and when the process CWD cannot be
written to llama.cpp's output is redirected into the converter's input folder.
That input folder is not always `save_directory`: a non-PEFT model with a local
`_name_or_path` is converted from its own checkpoint, which
`unsloth_save_pretrained_gguf` swaps in before calling `save_to_gguf`. The
preflight probed the requested output instead, so with the two on different
filesystems it measured a disk nothing was written to while the intermediate
GGUF filled the checkpoint's.

The conversion probe now resolves the same input folder the export will use,
by the same condition `_gguf_writes_16bit_checkpoint` already reads.

* Charge a split export for the conversion that lands beside its checkpoint

The split branch charges the filesystem holding `save_directory` for the
checkpoint alone, and the working-directory check charged whatever filesystem
the intermediate conversion is written to for the conversion alone. When those
are the same disk - `save_directory` a mount whose `_gguf` sibling is elsewhere,
with the process working directory on the mount - both artefacts sit on it at
once and neither check ever added them up. A 60GB checkpoint and a 60GB
conversion both passed on 100GB, and then it filled.

The conversion is now added to the split branch's figure when it lands on that
same filesystem. `_shares_filesystem` answers that, and unlike
`_on_separate_filesystems` it says no to a path it cannot identify, because
here the answer adds a charge rather than removing one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reserve for the merge alone, and follow the dict the writer follows

Four corrections to where the merge guard's 5% reserve is charged, and to how
a caller's state dict is read.

`_preflight_merge_disk` reserved around the whole estimate. Only the 16-bit
merge is written by `merge_and_overwrite_lora`, so only that part is measured
by its `free * 0.95`. A full-model `"lora"` save goes straight through
`save_pretrained`, and the torchao path merges into a temp directory and
leaves only the quantized sibling here, so neither pays a reserve at all. On a
20GB Kaggle working directory the over-charge relocated every output between
19.0GB and 20.0GB into a /tmp the kernel does not keep.

The GGUF split path compared the checkpoint plus its cache copy against the
reserved checkpoint with `max`, when the cache is written first and is still
resident when the guard runs. 16GB of checkpoint with a 14GB cache on 30.5GB
passed at 30GB and then the merge saw 16.5GB and refused 16GB. Added, this
band drops the optional pre-warm rather than failing the export.

`_full_model_checkpoint_bytes` selected the caller's dict on truthiness, while
`unsloth_generic_save` forwards it on `is not None`. So an explicitly empty
dict wrote no tensors and was priced as the whole resident model.

And compressed and torchao build their output as `save_directory + "-<suffix>"`,
which is lexical, so a symlinked `save_directory` puts the sibling on the
filesystem the probe never asked about. Warned rather than redirected: the
caller derives the sibling from whatever this returns, so a redirect already
moves both, and the uncovered case is the one where no redirect fires.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Follow the dict a 16-bit save writes, and phase the merge reserve

The generic writer only builds a state dict when it was handed none, so a
caller-supplied one is what the 16-bit checkpoint costs; the other writer
rebuilds it from the merged layers and the resident model is still the
right figure there.

A split export whose conversion lands beside its checkpoint was charged
the merge reserve and the conversion together. The merge guard runs
before the conversion is written, so the requirement is the taller of the
two phases: 60GB of merge and 60GB of conversion fit in 122GB, and the
sum asked 123.2GB and refused.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Ask each writer what it does with the dict, and the config what dtype it holds

Four sizing inputs the preflights were guessing at.

`unsloth_save_model` rebuilds the state dict from the merged layers only on
the path that walks `.model.layers`. Anything else takes its generic fallback,
which calls `save_pretrained(**save_pretrained_settings)` with the caller's
dictionary still in it and with no cast, so the checkpoint is that
dictionary's own bytes at its own dtypes. A 4GB fp32 dictionary was priced as
a 2GB merge.

compressed-tensors and torchao merge to 16 bits through `unsloth_generic_save`
as well, forwarding the same dictionary, so their kept or staged merge is
sized from it too and not only a literal `merged_16bit` request.

`_preflight_gguf_disk` was left on its `model_dtype = "f16"` default while
`save_to_gguf` reads the config. `estimate_gguf_export_bytes` drops a
requested output that equals the initial conversion, so a bf16 model asked for
["f16", "q4_k_m"] was charged one 16-bit file where the export writes a bf16
intermediate and a separate f16 output: 37.2GB asked against 52.4GB written on
Qwen3-8B.

And the pre-warmed base model is charged to the filesystem that actually holds
the Hugging Face cache. `save_directory` being a mount is the premise of the
split branch, and then `~/.cache` and the lexical `_gguf` sibling are both on
the parent disk: charging the checkpoint's filesystem dropped a pre-warm that
had room and let the sibling accept the GGUF files alone on a disk the base
was about to be downloaded onto.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop bf16 to f16 before sizing, and charge the cache where the conversion lands

Two filesystems' worth of undercount, both in the GGUF preflight.

`save_to_gguf` switches a bf16 initial conversion to f16 on hardware with no
bf16, and it does so AFTER resolving one. The preflight only normalized the
dtype it was told, so an explicit `first_conversion="bf16"` went through
untouched, and so did the single direct-convert method
`_choose_first_conversion` hands back. The estimate omits an output equal to
the initial conversion, so `["bf16"]` on a T4 was priced as one 16-bit file
where the export writes an f16 intermediate and a separate bf16 output:
30.5GB charged against 45.8GB written on Qwen3-8B, 15.3GB short.

The other is a third filesystem. With the output on an external drive and the
writable CWD sharing the machine's own disk with the Hugging Face cache, the
conversion check charged the intermediate alone. The pre-warm downloads the
base onto that same disk first and leaves it there, and its own gate does not
catch it either: it asks for two base copies free, and an f32 conversion is
two base copies on its own. 38.1GB free clears the pre-warm's 30.5GB
threshold and the 30.5GB conversion check, then the conversion writes 30.5GB
into the 22.8GB the cached base left. The cache is now resolved once, above
the split, and charged on the conversion's filesystem too - dropping the
pre-warm rather than refusing, since the raise for a conversion that does not
fit at all still runs first.

The pre-warm's skip message travels with the flag now, because more than one
filesystem can clear it and each has to name the one it measured.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Reserve the merge guard's 5% only where the guard runs

`merge_and_overwrite_lora` and its `free * 0.95` have one caller here:
`unsloth_generic_save`, on its adapter branch. The preflight was charging
the reserve to every 16-bit merge that was not written verbatim, including
`unsloth_generic_save_pretrained_merged`'s no-adapter save, which casts the
supplied dict and writes it with a bare `save_pretrained`.

The reserve is now driven by whether that writer runs, which is separate
from the sizing: a compressed export really is cast to two bytes by the same
writer and keeps that sizing whether or not there is an adapter to merge.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Charge the cache pre-warm to the filesystem it is written to

The single-filesystem branch of the GGUF preflight added the pre-warmed base
copy to need_with_cache and then compared that total against the free space on
save_directory, whatever filesystem the Hugging Face cache is actually on.
HF_HOME on a data volume is the ordinary layout on a machine with more than one
disk, and there the cached base never lands on the export disk at all.

Measured with a 60GB checkpoint, 40GB of quants and a 60GB cached base on 120GB
free: the export needs 100GB on that disk and fits, but the fictitious cache
copy takes the ask to 160GB and the pre-warm is dropped, so the next export
downloads the whole base again. That is the re-download the pre-warm exists to
stop. With the cache on its own filesystem the ask is now 100GB and the
pre-warm survives; with the cache on the export disk it is still 160GB and the
pre-warm is still dropped.

The split branch already did this accounting for the checkpoint and the sibling,
so the predicate moves above the split and both branches read one answer. The
figure can only fall, and only the pre-warm decision reads it, so no export that
fit before is refused now. An unresolvable cache stays charged where it was.

* Size the Kaggle redirect from the phased peak, and say when a cancelled one leaves no room

Two holes on the Kaggle side of the preflight.

The GGUF redirect was asked for the aggregate while the refusal below it
reads the phased peak of a disposable merge. On the Nemotron shape - a
63GB merge, a 60GB intermediate, an 18GB Q4_K_M - the ask was 141.0GB and
the peak is 123.0GB, so a /kaggle/working with 132.0GB free measured too
small and the export was relocated to /tmp, which the kernel does not keep
as notebook output. The redirect now asks the same 123.0GB, under the same
predicates as the branch that lowers the figure, read against the directory
before any move. It can only ever lower the ask.

A cancelled torchao redirect was silent. With a 10GB staging merge and a
5.0GB fp8 sibling, /kaggle/working at 4.0GB free and /tmp at 12.0GB, the
redirect fires because the sibling does not fit here and is then cancelled
because /tmp cannot hold the sibling and the staging merge together. That
is still the right move, but it hands the export back a filesystem measured
too small for it, and nothing downstream measures that: the merge is staged
in TMPDIR, so the merge guard's free * 0.95 covers the staging disk only,
and the sibling is written at the very end of a long quantization. It now
says so, with both figures. A warning and not a refusal, like the two
warnings beside it: this preflight never raises and the sibling is an
estimate. An unmeasured move (UNSLOTH_KAGGLE_USE_TMP=1) stays silent.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Re-ask the Kaggle redirect at the phased peak when this directory cannot be reclaimed

A save directory that already holds a checkpoint yields nothing for
_free_merge_if_disk_is_tight to reclaim, so the redirect above it is asked for
the aggregate. The move, though, writes the merge into a directory of the
export's own, where the reclamation is available: on the Nemotron-3-Nano-30B-A3B
shape (63GB merge, 60GB intermediate, 18GB Q4_K_M) that is a 141GB ask against a
130GB overlay, declined, and the export refused in place at 141GB on 100GB free,
when relocating peaks at 123GB and fits.

The first ask keeps the aggregate. Only after the move is declined, and only
where this filesystem is already short of the figure the refusal will read, is
it asked again at the peak. Asking the peak outright instead is a regression:
130GB free here holds the peak but not the aggregate, so the lower ask cancels
a move the export needs and the refusal reads 141GB anyway.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-13 18:56:43 -07:00
..
gpt-oss-merge tests: make pytest tests collect cleanly and drop the deleted QVQ registry mirror (#8206) 2026-08-08 20:03:55 -07:00
language_models tests: make pytest tests collect cleanly and drop the deleted QVQ registry mirror (#8206) 2026-08-08 20:03:55 -07:00
non_peft tests: make pytest tests collect cleanly and drop the deleted QVQ registry mirror (#8206) 2026-08-08 20:03:55 -07:00
text_to_speech_models Keep an explicitly requested float32 model in float32 without bfloat16 (#7867) 2026-08-09 05:12:02 -07:00
vision_models tests: make pytest tests collect cleanly and drop the deleted QVQ registry mirror (#8206) 2026-08-08 20:03:55 -07:00
run_offline_gguf_integration.py avoid Hub metadata probe when loading tokenizers with local_files_only (#7482) 2026-07-27 05:59:48 -07:00
test_compressed_export_schemes.py Add FP8/FP4 compressed export to save_pretrained_merged (#6706) 2026-06-30 03:40:16 -07:00
test_export_api_surface.py Add FP8/FP4 compressed export to save_pretrained_merged (#6706) 2026-06-30 03:40:16 -07:00
test_export_dispatch.py Studio: only clean up GGUF artifacts the export owns (#7940) 2026-08-09 03:59:59 -07:00
test_fix_sentencepiece_gguf_robustness.py tests: make pytest tests collect cleanly and drop the deleted QVQ registry mirror (#8206) 2026-08-08 20:03:55 -07:00
test_fix_sentencepiece_tokenizer_guard.py tests: make pytest tests collect cleanly and drop the deleted QVQ registry mirror (#8206) 2026-08-08 20:03:55 -07:00
test_gguf_disk_preflight.py Use Kaggle's large overlay for saves, and refuse a GGUF export that cannot fit (#8439) 2026-08-13 18:56:43 -07:00
test_gguf_export_and_inference.py Add FP8/FP4 compressed export to save_pretrained_merged (#6706) 2026-06-30 03:40:16 -07:00
test_gguf_single_pass_export.py Free the intermediate 16-bit merge when the GGUF quants will not fit (#8500) 2026-08-13 04:42:31 -07:00
test_imatrix_export.py Add FP8/FP4 compressed export to save_pretrained_merged (#6706) 2026-06-30 03:40:16 -07:00
test_is_gpt_oss_detection.py Reduce comments across recent fixes (#6776) 2026-06-30 23:13:36 -07:00
test_is_vlm_detection.py Guard VLM detection against config.architectures being None in the save paths (#7372) 2026-07-31 23:37:34 -03:00
test_llm_compressor_install_pin.py Pin llm-compressor auto-install to a vetted version range (#6778) 2026-07-01 04:48:38 -07:00
test_normalize_tied_weights_keys.py Fix save crash for legacy list-form _tied_weights_keys (NemotronH) (#6540) 2026-06-22 02:11:46 -07:00
test_offline_gguf_real_cache_integration.py avoid Hub metadata probe when loading tokenizers with local_files_only (#7482) 2026-07-27 05:59:48 -07:00
test_offline_gguf_vlm_tokenizer_7481.py avoid Hub metadata probe when loading tokenizers with local_files_only (#7482) 2026-07-27 05:59:48 -07:00
test_patch_saving_none_tokenizer.py Fix tokenizer save gemma (#5115) 2026-04-22 09:03:20 -07:00
test_preserve_tokenizer_eos_token.py Reduce and tighten comments and docstrings across the test suite (#6429) 2026-06-18 01:07:09 -07:00
test_prewarm_base_model_hub_cache.py Replace standalone Studio wording with Unsloth (#7221) 2026-07-19 00:47:04 -07:00
test_quant_method_none_normalization.py fix: map None quant method to q8_0 before lowercasing in GGUF export (#6889) 2026-07-06 07:11:49 -07:00
test_qwen3_5_vlm_full_finetune_key_remap.py Merge qwen35_export CI fixes 2026-06-08 22:02:05 +05:30
test_save_shell_injection.py Add FP8/FP4 compressed export to save_pretrained_merged (#6706) 2026-06-30 03:40:16 -07:00
test_save_subprocess_utf8_encoding.py Reduce and tighten comments and docstrings across the test suite (#6429) 2026-06-18 01:07:09 -07:00
test_torchao_remote_code_consent.py Fix export-time trust_remote_code bypass in FP8/INT8/GGUF-LoRA export (#6869) 2026-07-05 05:16:39 -07:00
test_unsloth_save.py Reduce and tighten comments and docstrings across the test suite (#6429) 2026-06-18 01:07:09 -07:00