Skip to content

Commit f759022

Browse files
committed
fix: add a mechanism for allowing more max tokens
Signed-off-by: Matt Kornfield <mkornfield@nvidia.com>
1 parent 1129bf3 commit f759022

7 files changed

Lines changed: 100 additions & 9 deletions

File tree

src/nemo_safe_synthesizer/config/generate.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,28 @@ class GenerateParameters(Parameters, BaseModel):
297297
),
298298
] = 0
299299

300+
max_tokens_multiplier: Annotated[
301+
float,
302+
ValueValidator(value_func=lambda v: v > 0),
303+
Field(
304+
title="max_tokens_multiplier",
305+
description=(
306+
"Margin applied to the longest tokenized training example "
307+
"(``max_tokens_per_example``) when sizing the per-sample generation "
308+
"budget (``SamplingParams.max_tokens``). The effective budget is "
309+
"``int(max_tokens_per_example * max_tokens_multiplier)``, still clamped "
310+
"to the remaining context window. The default adds only a small jitter "
311+
"margin, which suffices for most tables but is too tight for long, "
312+
"unbounded free-text columns: a model that over-generates slightly past "
313+
"the longest training example truncates mid-JSON and produces no "
314+
"parseable record (``length``-dominated finish reasons, zero valid "
315+
"records). Raise this (e.g. to 1.5-2.0) to give the model room to emit "
316+
"the closing tokens on such datasets; there is no benefit to values that "
317+
"push the budget past the context window. Must be > 0."
318+
),
319+
),
320+
] = 1.2 # mirrors llm.metadata.GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER (kept a literal to avoid a config->llm import cycle)
321+
300322
structured_generation: StructuredGenerationParameters = Field(
301323
description="Structured generation parameters controlling schema-constrained output.",
302324
default_factory=StructuredGenerationParameters,

src/nemo_safe_synthesizer/generation/timeseries_backend.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -921,7 +921,10 @@ def generate(
921921
top_p=self.config.generation.top_p,
922922
top_k=FIXED_RUNTIME_GENERATE_ARGS["top_k"],
923923
min_p=FIXED_RUNTIME_GENERATE_ARGS["min_p"],
924-
max_tokens=self.model_metadata.generation_max_tokens_for(self._get_prompt_token_count()),
924+
max_tokens=self.model_metadata.generation_max_tokens_for(
925+
self._get_prompt_token_count(),
926+
multiplier=self.config.generation.max_tokens_multiplier,
927+
),
925928
skip_special_tokens=True,
926929
include_stop_str_in_output=False,
927930
ignore_eos=False,

src/nemo_safe_synthesizer/generation/vllm_backend.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,10 @@ def _run_generation(self, data_actions_fn: utils.DataActionsFn | None) -> None:
746746
top_p=self.config.generation.top_p,
747747
top_k=FIXED_RUNTIME_GENERATE_ARGS["top_k"],
748748
min_p=FIXED_RUNTIME_GENERATE_ARGS["min_p"],
749-
max_tokens=self.model_metadata.generation_max_tokens_for(self._get_prompt_token_count()),
749+
max_tokens=self.model_metadata.generation_max_tokens_for(
750+
self._get_prompt_token_count(),
751+
multiplier=self.config.generation.max_tokens_multiplier,
752+
),
750753
skip_special_tokens=not need_special_token_outputs,
751754
include_stop_str_in_output=need_special_token_outputs,
752755
ignore_eos=False,

src/nemo_safe_synthesizer/llm/metadata.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,13 +462,13 @@ def max_seq_length(self) -> int:
462462
rsf = self.rope_scaling.factor
463463
return int((self.base_max_seq_length or DEFAULT_MAX_SEQ_LENGTH) * rsf)
464464

465-
def generation_max_tokens_for(self, prompt_len: int) -> int:
465+
def generation_max_tokens_for(self, prompt_len: int, multiplier: float | None = None) -> int:
466466
"""Per-sample ``max_tokens`` ceiling, prompt-aware.
467467
468468
Returns the smaller of:
469469
470-
1. ``int(max_tokens_per_example * GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER)``
471-
when the assembler stat is populated, else ``max_seq_length``.
470+
1. ``int(max_tokens_per_example * multiplier)`` when the assembler stat
471+
is populated, else ``max_seq_length``.
472472
2. ``max_seq_length - prompt_len`` -- vLLM raises when
473473
``len(prompt) + max_tokens > max_model_len``
474474
(`vllm#33418 <https://github.com/vllm-project/vllm/issues/33418>`_).
@@ -480,15 +480,29 @@ def generation_max_tokens_for(self, prompt_len: int) -> int:
480480
clamp is a defensive belt for legacy adapters where the assembler
481481
stat is missing and for prompts longer than those seen in training.
482482
483+
The default ``multiplier`` (``GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER``)
484+
adds only a small jitter margin, which is enough for most tables but
485+
too tight for long, unbounded free-text columns: a model that
486+
over-generates slightly past the longest training example truncates
487+
mid-JSON and yields no parseable record. Callers wire the user-facing
488+
``generation.max_tokens_multiplier`` knob through here to widen the
489+
budget (bounded by the context window) for such datasets.
490+
483491
Args:
484492
prompt_len: Tokenized length of the prompt this sample will
485493
run against. Pass ``0`` to disable the prompt clamp.
494+
multiplier: Margin applied to ``max_tokens_per_example``. Defaults
495+
to ``GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER`` when ``None`` so
496+
non-generation callers (e.g. the training eval callback) keep
497+
the legacy sizing.
486498
487499
Returns:
488500
Non-negative ``max_tokens`` value safe to feed to ``SamplingParams``.
489501
"""
502+
if multiplier is None:
503+
multiplier = GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER
490504
if self.max_tokens_per_example and self.max_tokens_per_example > 0:
491-
sized = int(self.max_tokens_per_example * GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER)
505+
sized = int(self.max_tokens_per_example * multiplier)
492506
else:
493507
sized = self.max_seq_length
494508
return max(0, min(sized, self.max_seq_length - prompt_len))

tests/config/test_generate.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,22 @@ def test_legacy_keys_with_no_nested_dict_are_migrated(self) -> None:
209209
assert params.structured_generation.backend == "xgrammar"
210210
assert params.structured_generation.schema_method == "structural_tag"
211211
assert params.structured_generation.use_single_sequence is True
212+
213+
214+
@pytest.mark.unit
215+
class TestMaxTokensMultiplier:
216+
def test_default_matches_metadata_constant(self) -> None:
217+
"""The config default mirrors the metadata safety-margin constant."""
218+
from nemo_safe_synthesizer.llm.metadata import GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER
219+
220+
assert GenerateParameters().max_tokens_multiplier == GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER
221+
222+
def test_accepts_widened_value(self) -> None:
223+
"""Users can widen the budget for long free-text datasets."""
224+
assert GenerateParameters(max_tokens_multiplier=1.8).max_tokens_multiplier == 1.8
225+
226+
@pytest.mark.parametrize("value", [0, -0.5])
227+
def test_rejects_non_positive(self, value: float) -> None:
228+
"""Non-positive multipliers are rejected by the validator."""
229+
with pytest.raises(ValidationError):
230+
GenerateParameters(max_tokens_multiplier=value)

tests/generation/test_vllm_backend.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,8 +1048,10 @@ def capture_and_stop(**kwargs):
10481048
assert captured["max_tokens"] == 4_200
10491049
# Engine is not initialized in this plumbing test, so the cached
10501050
# prompt-token count falls back to 0; the helper is still called
1051-
# exactly once with that value.
1052-
mock_model_metadata.generation_max_tokens_for.assert_called_once_with(0)
1051+
# exactly once with that value plus the configured budget multiplier.
1052+
mock_model_metadata.generation_max_tokens_for.assert_called_once_with(
1053+
0, multiplier=base_params.generation.max_tokens_multiplier
1054+
)
10531055

10541056
def test_passes_cached_prompt_token_count_when_engine_initialized(
10551057
self, base_params, mock_model_metadata, mock_schema, mock_workdir
@@ -1079,7 +1081,9 @@ def capture_and_stop(**kwargs):
10791081
backend.generate()
10801082

10811083
assert captured["max_tokens"] == 4_096
1082-
mock_model_metadata.generation_max_tokens_for.assert_called_once_with(37)
1084+
mock_model_metadata.generation_max_tokens_for.assert_called_once_with(
1085+
37, multiplier=base_params.generation.max_tokens_multiplier
1086+
)
10831087
# Cached: a second access does not retokenize.
10841088
assert backend._get_prompt_token_count() == 37
10851089
fake_tokenizer.encode.assert_called_once_with(backend.prompt)

tests/llm/test_metadata.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,32 @@ def test_metadata_generation_max_tokens_for_never_returns_negative(self, sample_
689689
# Beyond-context prompts still produce a safe value vLLM can accept.
690690
assert sample_model_metadata.generation_max_tokens_for(sample_model_metadata.max_seq_length + 64) == 0
691691

692+
def test_metadata_generation_max_tokens_for_none_multiplier_uses_default(self, sample_model_metadata):
693+
"""``multiplier=None`` reproduces the legacy safety-margin sizing."""
694+
sample_model_metadata.max_tokens_per_example = 1000
695+
expected = int(1000 * GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER)
696+
assert sample_model_metadata.generation_max_tokens_for(10, multiplier=None) == expected
697+
# Explicitly passing the default constant matches the None path.
698+
assert (
699+
sample_model_metadata.generation_max_tokens_for(10, multiplier=GENERATION_MAX_TOKENS_SAFETY_MULTIPLIER)
700+
== expected
701+
)
702+
703+
def test_metadata_generation_max_tokens_for_custom_multiplier_widens_budget(self, sample_model_metadata):
704+
"""A larger multiplier widens the stat-derived budget (the long-text fix)."""
705+
sample_model_metadata.max_tokens_per_example = 1000
706+
# 1000 * 1.8 = 1800, still within the 2048 window given a small prompt.
707+
assert sample_model_metadata.generation_max_tokens_for(10, multiplier=1.8) == 1800
708+
709+
def test_metadata_generation_max_tokens_for_custom_multiplier_still_clamped_to_window(self, sample_model_metadata):
710+
"""The prompt/window clamp still binds even with an aggressive multiplier."""
711+
assert sample_model_metadata.max_seq_length == 2048
712+
sample_model_metadata.max_tokens_per_example = 1500 # * 4.0 = 6000, far past the window
713+
prompt_len = 48
714+
assert sample_model_metadata.generation_max_tokens_for(prompt_len, multiplier=4.0) == (
715+
sample_model_metadata.max_seq_length - prompt_len
716+
)
717+
692718
def test_metadata_max_tokens_per_example_round_trips_through_metadata_json(self, sample_model_metadata):
693719
"""``max_tokens_per_example`` persists through save → load."""
694720
sample_model_metadata.max_tokens_per_example = 1500

0 commit comments

Comments
 (0)