Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/inference_endpoint/commands/benchmark/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,14 @@ def _run(
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}"
for err in e.errors()
)
raise DatasetValidationError(f"Invalid --dataset: {msgs}") from e
# --dataset parse failures aren't yet mapped to a specific Reason.
raise DatasetValidationError(
DatasetValidationError.Reason.UNSPECIFIED, f"Invalid --dataset: {msgs}"
) from e
except ValueError as e:
raise DatasetValidationError(f"Invalid --dataset: {e}") from e
raise DatasetValidationError(
DatasetValidationError.Reason.UNSPECIFIED, f"Invalid --dataset: {e}"
) from e
if config.audit is None:
run_benchmark(config, mode)
return
Expand Down
8 changes: 8 additions & 0 deletions src/inference_endpoint/commands/benchmark/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,14 @@ def _load_datasets(
except Exception as e:
raise SetupError(f"Failed to load dataset: {e}") from e

# Fail fast on a warmup dataset that salt cannot bust — at load time,
# before any worker/aggregator subprocess is spawned. with_salt() runs
# the same check later; this earlier call is deliberate (not redundant),
# so an invalid dataset aborts before the subprocess fan-out.
warmup = config.settings.warmup
if warmup.enabled and warmup.salt:
dataloader.validate_saltable()

if perf_cfg.accuracy_config is not None:
accuracy_config = perf_cfg.accuracy_config
scorer_cls, extractor_cls = _resolve_accuracy_components(
Expand Down
13 changes: 11 additions & 2 deletions src/inference_endpoint/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,10 +759,19 @@ class WarmupConfig(BaseModel):
bool,
cyclopts.Parameter(
alias="--warmup-salt",
help="Prepend a unique random hex salt to each warmup prompt",
help=(
"Prepend a unique random hex salt to each warmup prompt. Requires "
"text-'prompt' samples; enabling it on a pre-tokenized "
"('input_tokens') dataset is a hard error."
),
),
] = Field(
True, description="Prepend a unique random hex salt to each warmup prompt"
False,
description=(
"Prepend a unique random hex salt to each warmup prompt. Requires "
"text-'prompt' samples; enabling it on a pre-tokenized "
"('input_tokens') dataset is a hard error."
),
)
drain: Annotated[
bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ settings:
warmup:
enabled: false # Enable warmup phase before performance run
n_requests: null # Warmup request count (None = full dataset once)
salt: true # Prepend a unique random hex salt to each warmup prompt
salt: false # Prepend a unique random hex salt to each warmup prompt. Requires text-'prompt' samples; enabling it on a pre-tokenized ('input_tokens') dataset is a hard error.
drain: false # Drain in-flight warmup requests before starting the performance phase
warmup_random_seed: 42 # RNG seed for warmup scheduling and sample ordering
profiling:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ settings:
warmup:
enabled: false # Enable warmup phase before performance run
n_requests: null # Warmup request count (None = full dataset once)
salt: true # Prepend a unique random hex salt to each warmup prompt
salt: false # Prepend a unique random hex salt to each warmup prompt. Requires text-'prompt' samples; enabling it on a pre-tokenized ('input_tokens') dataset is a hard error.
drain: false # Drain in-flight warmup requests before starting the performance phase
warmup_random_seed: 42 # RNG seed for warmup scheduling and sample ordering
profiling:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ settings:
warmup:
enabled: false # Enable warmup phase before performance run
n_requests: null # Warmup request count (None = full dataset once)
salt: true # Prepend a unique random hex salt to each warmup prompt
salt: false # Prepend a unique random hex salt to each warmup prompt. Requires text-'prompt' samples; enabling it on a pre-tokenized ('input_tokens') dataset is a hard error.
drain: false # Drain in-flight warmup requests before starting the performance phase
warmup_random_seed: 42 # RNG seed for warmup scheduling and sample ordering
profiling:
Expand Down
100 changes: 63 additions & 37 deletions src/inference_endpoint/dataset_manager/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from datasets import load_dataset, load_from_disk

from ..config.schema import APIType, ModelParams
from ..exceptions import DatasetValidationError
from .transforms import (
ColumnFilter,
Transform,
Expand Down Expand Up @@ -257,6 +258,24 @@ def load_from_huggingface(
return ds[split].to_pandas()


def _can_salt(sample: Any) -> DatasetValidationError.Reason | None:
"""Return the Reason a sample cannot be salted, or None if it can.

Salt requires a dict sample with a str 'prompt' and no 'input_tokens' (which
adapters send verbatim, so a salted 'prompt' would not reach the server).
"""
Reason = DatasetValidationError.Reason
if not isinstance(sample, dict):
return Reason.TYPE_MISMATCH
if "input_tokens" in sample:
return Reason.INPUT_TOKENS_SHADOWING
if "prompt" not in sample:
return Reason.PROMPT_MISSING
if not isinstance(sample["prompt"], str):
return Reason.PROMPT_TYPE_MISMATCH
return None


class Dataset:
"""Class for loading and managing benchmark datasets.

Expand Down Expand Up @@ -440,55 +459,62 @@ def load_sample(self, index: int) -> Any:
data = self._apply_salt(data)
return data

def validate_saltable(self) -> None:
"""Raise if any loaded sample cannot be salted.

salt requires a dict sample with a text ('str') 'prompt' and no
'input_tokens' (adapters send those verbatim, so a salted 'prompt' would
never reach the server). A non-saltable sample is an error, not a silent
skip: skipping would leave the KV cache un-busted. Every sample is
checked — a single invalid item fails the run, because the seeded warmup
subset can draw any index and salt correctness is all-or-nothing. Called
before any load is issued — at benchmark setup and again from with_salt().

Raises:
DatasetValidationError: naming the first offending sample. The index
is into the loaded, post-transform sample order, not the source
file line.
"""
assert self.data is not None, "Dataset not loaded. Call load() first."
for i, sample in enumerate(self.data):
reason = _can_salt(sample)
if reason is not None:
raise DatasetValidationError(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should have been caught earlier, but that is something to address -
exceptions.py is very poorly designed and only contains bare name aliases for particular failure states.

This construction of error message and _salt_violation should be part of the DatasetValidationError class:

class DatasetValidationError:
    ERR_MSG_TEMPLATE = "salt=True requires ... but sample {index} {reason} ..."

    def __init__(self, dataset_idx: int, sample: Sample):
        self.reason = self._violation_reason(sample)
        err_msg = DatasetValidationError.ERR_MSG_TEMPLATE.format(index=dataset_idx, reason=self.reason)
        super().__init__(err_msg)

    def _violation_reason(self, sample: Sample):
        # Body of `_salt_violation` here.

Ideally, reason should be some enum or object rather than a raw string, smth like

class DatasetValidationError:
     ...

    class Reason(Enum):
         TypeMismatch = ...
         InputTokensShadowing = ...
         PromptMissing = ...
         PromptTypeMismatch = ...
         Other = ...

         def fmt_str(self, sample):
              <handle conversion to full error reason string>
     ...

reason,
detail=(
f"sample {i} (index into the loaded, post-transform "
f"order); disable salt (--warmup-salt / warmup.salt: "
f"false) or use a text-prompt dataset"
),
)

def with_salt(self, rng: random.Random) -> "Dataset":
"""Return a shallow copy of this dataset that salts each load_sample() call.

The returned dataset shares the same loaded data — no re-loading needed.
Each load_sample() call on the returned dataset prepends a unique hex salt
derived from rng to the prompt field, preventing KV-cache reuse.
derived from rng to the 'prompt' field, preventing KV-cache reuse.

Validates every sample first (see validate_saltable): a non-saltable
dataset raises here, before any load is issued.

Raises:
DatasetValidationError: if any sample cannot be salted.
"""
self.validate_saltable()
clone = copy.copy(self)
clone._salt_rng = rng
return clone

def _apply_salt(self, data: Any) -> Any:
"""Prepend a unique salt to the prompt field of a sample dict."""
def _apply_salt(self, data: dict[str, Any]) -> dict[str, Any]:
"""Prepend a unique salt to the 'prompt' field.

with_salt() has validated every sample, so ``data`` is guaranteed to be a
dict with a str 'prompt' and no 'input_tokens'.
"""
assert self._salt_rng is not None
if not isinstance(data, dict):
return data
if "input_tokens" in data and "prompt" not in data:
self.logger.warning(
"salt=True: sample has 'input_tokens' but no 'prompt' — "
"salt cannot be applied to pre-tokenized input; KV-cache reuse may not be prevented"
)
return data
if "input_tokens" in data and "prompt" in data:
self.logger.warning(
"salt=True: sample has both 'input_tokens' and 'prompt' — "
"salt applied to 'prompt' only; adapters that use 'input_tokens' "
"directly will still reuse the KV cache"
)
if "prompt" not in data:
return data
prompt = data["prompt"]
salt = self._salt_rng.randbytes(8).hex()
if isinstance(prompt, str):
return {**data, "prompt": f"[{salt}] {prompt}"}
if isinstance(prompt, list) and prompt:
# Find the first text part at any index (image-first prompts place text at index 1+)
for i, part in enumerate(prompt):
if isinstance(part, dict) and part.get("type") == "text":
salted_parts = [
*prompt[:i],
{**part, "text": f"[{salt}] {part['text']}"},
*prompt[i + 1 :],
]
return {**data, "prompt": salted_parts}
self.logger.warning(
"salt=True: multimodal prompt has no text part — "
"salt cannot be applied; KV-cache reuse may not be prevented"
)
return data # unsupported prompt type — skip salting
return {**data, "prompt": f"[{salt}] {data['prompt']}"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have 2 questions:

  1. Have we studied whether or not injecting these query-irrelevant random strings at the beginning affects accuracy in a format-sensitive workload like GPT-OSS that uses harmonize?
  2. Is it possible to salt a token_ids list by injecting a random list of ints sampled from a set of known tokens that do not represent special markers?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    • not yet, we can do that but the goal is to eliminate kv-cache reuse between the warmup and performance by changing the prefix of the user prompt. For something like gpt-oss, we need a haromize aware salting mechanism.
  1. That is the long term plan - to have each adapter add sensible salt. For now, with the text "prompt" column datasets, we can inject salt, but others require understanding the structure and picking valid tokens.


def num_samples(self) -> int:
assert self.data is not None, "Dataset not loaded. Call load() first."
Expand Down
29 changes: 27 additions & 2 deletions src/inference_endpoint/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

"""Custom exceptions for CLI error handling."""

from enum import Enum


class CLIError(Exception):
"""Base exception for CLI errors.
Expand All @@ -37,9 +39,32 @@ class InputValidationError(CLIError):


class DatasetValidationError(InputValidationError):
"""Invalid --dataset string or dataset configuration."""
"""Invalid --dataset string or dataset configuration.

pass
The failure category is a ``Reason``; ``detail`` carries the specifics
(offending sample index, remediation hint, parser error text).
"""

class Reason(Enum):
"""Why a dataset failed validation."""

TYPE_MISMATCH = "sample is not a dict"
INPUT_TOKENS_SHADOWING = (
"sample has 'input_tokens'; salt cannot bust a pre-tokenized cache"
)
PROMPT_MISSING = "sample has no 'prompt' field"
PROMPT_TYPE_MISMATCH = "sample 'prompt' is not a str"
UNSPECIFIED = "dataset validation failed"

def __init__(
self,
reason: "DatasetValidationError.Reason",
detail: str | None = None,
) -> None:
self.reason = reason
self.detail = detail
message = reason.value if detail is None else f"{reason.value}: {detail}"
super().__init__(message)


class SetupError(CLIError):
Expand Down
Loading
Loading