unsloth/tests/utils/test_truncation_attestation.py
Daniel Han 7b13a787cf
Studio: tokenize the dataset online for plain-text single-pass runs (#8960)
* Studio: tokenize the dataset online for plain-text single-pass runs

TRL's tokenizing map is the largest fixed cost of starting a text run:
71s of the 78s of preparation on 100k rows of OpenMathReasoning, with
dataset_num_proc already at 8. It is per-row string work, so it can run
in DataLoader workers while the GPU is busy instead of blocking the
start.

Four parts, all needed together:

  * datasets.with_transform attaches a batched tokenizer that runs on
    __getitem__. with_transform, not set_transform: the caller's split is
    also held by the preview and the row-count checks.
  * TRL gets dataset_kwargs = {"skip_prepare_dataset": True} so it does
    not run its own map over the view. Feature-detected on SFTConfig's
    fields plus SFTTrainer.__init__'s source, never assumed.
  * dataloader_num_workers / prefetch_factor / persistent_workers, sized
    from the same shared policy that sizes dataset_num_proc and capped at
    four.
  * a prewarm barrier inside _preflight_first_batch, which already built
    a loader and pulled a batch. It now drains max(grad_accum,
    workers * prefetch) microbatches, and memoizes the train loader --
    transformers caches only the eval ones, so without that train() forks
    a second worker set and drops everything the barrier filled.

The transform reproduces unsloth_zoo's sft_prepare_dataset tokenize step
exactly: same truncation and max_length, the same double-BOS rule, and
the tokenizer's whole output rather than input_ids alone, because the
collator and the attention dispatcher both branch on which keys are
present.

Default ON only for: Linux, plain text, plain tokenizer, map-style
datasets.Dataset, packing off, no custom collator, no completion masking,
not already tokenized, no token_type_ids, a raw eval split or none, at
least 10k rows, and at most one pass over the data. Everything else takes
today's path with config_args and the dataset wrapper untouched, and any
failure in the gate or the attach degrades the same way.
UNSLOTH_STUDIO_ONLINE_TOKENIZATION=0 forces it off; =1 lifts the two cost
gates but never a correctness gate.

The one-pass rule is what the measurements support: within a single pass
the workers stay ahead and there is no steady-state cost (225.45s eager
vs 225.33s online over 200 steps), while a lazy view re-tokenizes on
every further pass where Arrow would just be read.

rl.py: a split may now attest its own truncation width through
_unsloth_truncated_to, and the max_length enforcement believes it instead
of scanning. Scanning a lazily-tokenizing split reads every row, which is
the whole eager tokenize pass again, run inside __init__ where nothing
overlaps it -- and the fallback it would then take turns padding-free
off. Both copies of the scan honour it, the module-level one and the one
inlined into every generated trainer.

Measured on one B200, Qwen3-0.6B + LoRA, 100k rows, cold datasets cache:
preparation 71.2s -> 0.4s, time to first step 91.9s -> 17.7s. Losses
match: the largest per-step gap between the eager and online arms is
7e-4, smaller than the 9e-4 between two eager runs of the same seed.

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

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

* Shut the online path's workers down, and refuse the rows it would fail late on

Review of the online tokenization path found two things it got wrong once it
was running, rather than in the gate.

The persistent DataLoader workers were never torn down. Persistence is what
lets the prewarm barrier's workers survive into `train()`, and the memo holds
the loader that owns them, so after `train()` returned nothing dropped the
last reference: four `pt_data_worker` processes, 5.27 GB resident between
them, still alive through merging, quantizing and GGUF export, each a fork of
a process that had already initialised CUDA. Measured on one B200, Qwen3-0.6B
+ LoRA, 12k rows: 4 workers still there fifteen seconds after training ended,
and only the process exiting cleared them. `release_train_dataloader` shuts
them down and puts the real `get_train_dataloader` back, called from a
`finally` around `train()` so it runs before `_finalize_training` rather than
after it, and again from the outer `finally` for the paths that return before
training starts -- the preflight error has already forked the workers. Same
measurement after: 4 workers before the release, 0 after. An accelerate
wrapper and the loader inside it share one iterator, so the walk counts a
worker set once and clears the reference on both.

The second is the one asymmetry the gate did not cover. A null or non-string
row fails the eager map inside the trainer constructor, in seconds, before
anything else has happened; the lazy view reads a row only when the sampler
draws it, so the same dataset trained twenty steps and exited clean, and
would have died at whatever step drew row 137. That is the one way this
feature can make a failing run worse rather than slower. Both checks are
metadata -- the dtype off the schema, `null_count` off Arrow's per-chunk
statistics -- so neither reads a row, and a `select`ed split over-reports,
which vetoes a split that might have been fine and never the reverse. No
runtime fallback on top: switching a running job to the eager path would
tokenize the whole split mid-run and hide the bad data, where an error naming
the transform says what is actually wrong.

Also:

- The Linux gate tested `sys.platform`, but the hazard it names is `spawn`
  re-importing the entry point against a `sys.path` Studio modified in
  process. A Linux host whose start method is set to spawn or forkserver is
  the identical hazard and a platform check cannot see it. Read the start
  method instead, via `allow_none` and the method list, since resolving it
  the other way pins the context and makes a later `set_start_method()`
  raise.
- The transform truncated to the `max_seq_length` the user asked for, while
  the generated `__init__` reduces that to the model's own cap before
  deriving `max_length` from it. Read the same cap, or the two paths stop
  producing the same rows and the attestation claims a width nothing applied.
- Delete `prewarm_dataloader`. It was called from nowhere, and its docstring
  described tearing the loader down so the workers do not survive, which is
  the opposite of what the shipped barrier does on purpose.
- `scripts/online_tokenization_ab.py` defaulted `--dataset` and `--model` to
  paths under one workspace. `--dataset` is required now and the rest resolve
  without them.
- Note in the module docstring that the pass gate counts train passes only:
  an eval split is re-tokenized on every evaluation, where the eager map
  tokenized it once.

The gate was well covered and the mechanism was not. Neutering `attach`,
`online_config_args` and the memo while leaving the gate saying yes left 64
of 72 tests passing. `test_online_tokenization_runtime.py` pins the three
claims that needed a real DataLoader with real forked workers to establish:
the prewarm re-iterates from the start instead of continuing (a sequential
sampler makes it exact -- continuing the prewarmed iterator loses exactly
`prewarm * batch` rows and starts at the wrong one), the loader the barrier
filled is the one handed back afterwards, and the workers are gone once
training is over.

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

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

* Probe the eval split's own BOS convention instead of reusing the train split's

TRL calls _prepare_dataset once per split, so the eager path derives
add_special_tokens from each split's own first row. The online path reused the
train split's answer for the eval view, which tokenizes eval differently from
the map it stands in for whenever the two splits disagree about a leading BOS.

Also correct the prewarm barrier's docstring. torch answers a second iter() on
a persistent-workers loader with _iterator._reset(), which restarts the sampler
at row 0 and drops what is in flight, so the drained batches are tokenized
again rather than handed to step 1. No rows are lost; what the barrier buys is
workers that are already forked and past their first tokenizer touch.

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

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

* Release the memoized eval loader's workers alongside the train loader

dataloader_num_workers and dataloader_persistent_workers are
TrainingArguments settings, so an online run with evaluation on forks the
same workers for the eval loader. Transformers parks that prepared loader
in _eval_dataloaders (Trainer._get_dataloader, unchanged from 4.51.3
through 5.5.0) and torch never drops _iterator on a persistent-workers
loader once it has been iterated, so those workers outlived train() and sat
resident through the merge and export that the existing cleanup exists to
protect. Drain and drop the memo too.

* Stop the online tokenization tests depending on the runner's TRL and torch

Two CPU CI environments were red for reasons that had nothing to do with
what the tests cover. The gate tests read the installed TRL through
trl_supports_skip_prepare_dataset, and the CPU job installs no TRL, so
every refusal reported the missing hook instead of the gate under test.
Pin it in the autouse fixture, the way sys.platform is already pinned, and
cover the detector and its veto directly instead.

The wiring tests import UnslothTrainer, which imports torch, at module
scope, so a runner without torch failed collection and interrupted the
whole run rather than skipping the module. Guard it with importorskip, as
the runtime tests already do.

* Tighten online tokenization comments

* Route a Hugging Face dataset id through dataset_source in the A/B harness

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-16 04:55:53 -07:00

253 lines
9 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""A split that truncates its own rows may say so instead of being scanned.
`pretokenized_within_cap` checks `max_length` by reading every row. On a
lazily-tokenizing `with_transform` view -- what Studio's online tokenization
produces -- reading a row is tokenizing it, so the scan runs the whole eager pass
the view exists to avoid, inside `__init__` where nothing overlaps it.
`_unsloth_truncated_to = N` is the escape: every row is already cut at N. Both
copies of the scan (this module's and the one `rl.py` inlines into every
generated trainer) must agree, so both run here; the inlined one is extracted
from the codegen string and executed, the only way to test source that only
exists as a string.
"""
from __future__ import annotations
import re
import sys
import textwrap
import types
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
RL_PATH = REPO_ROOT / "unsloth" / "models" / "rl.py"
def _load_rl_module():
"""`unsloth.models.rl` without importing unsloth: only the pure, stdlib-only
helpers are needed."""
import ast
source = RL_PATH.read_text(encoding = "utf-8")
tree = ast.parse(source)
wanted = {
"_attested_within_cap",
"pretokenized_within_cap",
"splits_within_cap",
"_SCAN_ROWS",
"_TRUNCATION_ATTESTATION_ATTR",
}
kept = [
node
for node in tree.body
if (isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in wanted)
or (
isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id in wanted for t in node.targets)
)
]
module = types.ModuleType("rl_helpers_under_test")
exec(compile(ast.Module(body = kept, type_ignores = []), str(RL_PATH), "exec"), module.__dict__)
return module
rl = _load_rl_module()
class _Lazy:
"""A split that rebuilds its rows on read and counts the reads: stands in for
`with_transform` without the `datasets` dependency, and can prove the scan
did not happen."""
def __init__(
self,
n,
width,
attest = None,
):
self.n = n
self.width = width
self.reads = 0
if attest is not None:
self._unsloth_truncated_to = attest
def __len__(self):
return self.n
def __getitem__(self, i):
self.reads += 1
return {"input_ids": [1] * self.width}
def __iter__(self):
for i in range(self.n):
yield self[i]
def test_an_attesting_split_is_believed_without_a_single_read():
split = _Lazy(100_000, width = 2048, attest = 2048)
assert rl.pretokenized_within_cap(split, 2048) is True
assert split.reads == 0, "the attestation was ignored and the split was scanned"
def test_a_split_attesting_a_wider_cap_is_refused():
"""Truncated to 4096 proves nothing about a 2048 cap, and the refusal must
not silently fall through to a scan that would find the same answer."""
split = _Lazy(8, width = 4096, attest = 4096)
assert rl.pretokenized_within_cap(split, 2048) is False
assert split.reads == 0
def test_a_split_attesting_a_narrower_cap_is_accepted():
split = _Lazy(8, width = 512, attest = 512)
assert rl.pretokenized_within_cap(split, 2048) is True
def test_a_split_with_no_attestation_is_still_scanned():
split = _Lazy(4, width = 8)
assert rl.pretokenized_within_cap(split, 2048) is True
assert split.reads == 4
def test_an_overlength_unattested_split_is_still_caught():
split = _Lazy(4, width = 9000)
assert rl.pretokenized_within_cap(split, 2048) is False
@pytest.mark.parametrize("claim", [True, False, "2048", 2048.0, None, object()])
def test_a_non_int_claim_is_not_an_attestation(claim):
"""`True` is an `int` in Python and would read as a cap of 1. Anything that
is not a plain integer falls through to the scan."""
split = _Lazy(4, width = 9000)
if claim is not None:
split._unsloth_truncated_to = claim
assert rl.pretokenized_within_cap(split, 2048) is False
def test_the_claim_is_read_from_the_split_itself_not_through_a_wrapper():
"""`_CappedBase.__getattr__` forwards unknown names to the split inside, so
a plain `getattr` would let an inner split's guarantee answer for a wrapper
that carries none."""
class _Forwarding:
def __init__(self, inner):
self._inner = inner
def __getattr__(self, name):
return getattr(self._inner, name)
def __len__(self):
return len(self._inner)
def __iter__(self):
for row in self._inner:
yield {"input_ids": row["input_ids"] * 8}
wrapper = _Forwarding(_Lazy(4, width = 4096, attest = 4096))
assert rl._attested_within_cap(wrapper, 40960) is None
def test_splits_within_cap_honours_the_attestation_per_split():
good = _Lazy(4, width = 2048, attest = 2048)
bad = _Lazy(4, width = 4096, attest = 4096)
assert rl.splits_within_cap({"a": good}, 2048) is True
assert rl.splits_within_cap({"a": good, "b": bad}, 2048) is False
# ------------------------------------------------- the copy rl.py inlines
def _inlined_within_cap(cap):
"""Build `_unsloth_within_cap` out of the codegen string and return it.
The generated trainer cannot import from `rl.py`, so the scan exists twice;
extracting and executing the literals is the only way to hold both copies to
the same verdict.
"""
source = RL_PATH.read_text(encoding = "utf-8")
start = source.index('" def _unsloth_within_cap(_ds):\\n"')
end = source.index('" def _unsloth_splits_within_cap(_ev):\\n"')
body = "".join(re.findall(r'^\s*"((?:[^"\\]|\\.)*)"\s*$', source[start:end], re.MULTILINE))
# Strip the indentation the literals carry for the generated `__init__`.
body = textwrap.dedent(body.encode().decode("unicode_escape"))
namespace = {"_unsloth_cap": cap}
exec(body, namespace)
return namespace["_unsloth_within_cap"]
@pytest.mark.parametrize(
"attest, width, cap, expected, expected_reads",
[
(2048, 2048, 2048, True, 0),
(512, 512, 2048, True, 0),
(4096, 4096, 2048, False, 0),
(None, 8, 2048, True, 4),
(None, 9000, 2048, False, 1),
],
)
def test_the_inlined_copy_gives_the_same_verdict(attest, width, cap, expected, expected_reads):
inlined = _inlined_within_cap(cap)
split = _Lazy(4, width = width, attest = attest)
assert inlined(split) is expected
assert split.reads == expected_reads
module_level = _Lazy(4, width = width, attest = attest)
assert rl.pretokenized_within_cap(module_level, cap) is expected
def test_the_codegen_and_the_module_agree_on_the_attribute_name():
"""A rename on one side and not the other is a silent no-op, not a failure:
the scan would simply never see an attestation again."""
source = RL_PATH.read_text(encoding = "utf-8")
assert rl._TRUNCATION_ATTESTATION_ATTR == "_unsloth_truncated_to"
assert (
source.count("'_unsloth_truncated_to'") >= 2
), "the codegen no longer reads the attribute the module writes"
def test_studio_stamps_the_attribute_this_scan_reads():
"""The producer and the consumer live in different packages; nothing but a
test ties the string they share together."""
studio = REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "online_tokenization.py"
if not studio.exists():
pytest.skip("studio backend not present in this checkout")
text = studio.read_text(encoding = "utf-8")
assert f'TRUNCATION_ATTESTATION_ATTR = "{rl._TRUNCATION_ATTESTATION_ATTR}"' in text
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
# ------------------------------------------- the generated block must still parse
def test_the_generated_max_length_block_is_valid_python():
"""The block only exists as string literals, so a stray indent or unclosed
bracket stays invisible until a user gets a `SyntaxError` from a generated
trainer. Assemble and parse it, reading the literals off the source rather
than running the generator, which needs a TRL this install may not have.
"""
import ast
source = RL_PATH.read_text(encoding = "utf-8")
start = source.index(" max_length_check = (")
end = source.index(" extra_args += max_length_check")
literals = re.findall(r'^\s*"((?:[^"\\]|\\.)*)"\s*$', source[start:end], re.MULTILINE)
block = "".join(literals).encode().decode("unicode_escape")
# The generator emits this at one indent level inside the trainer's __init__.
ast.parse(textwrap.dedent(block))
def test_the_attestation_branch_is_present_in_the_generated_block():
"""Pinned by name: without it a `with_transform` split loses padding-free and
is scanned row by row, which is the eager tokenize pass it exists to avoid."""
source = RL_PATH.read_text(encoding = "utf-8")
assert "_unsloth_attests" in source
assert "not _unsloth_prep_truncates and not _unsloth_eval_packing" in source