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
8 changes: 3 additions & 5 deletions config.default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ device_map = "auto"
# but may slightly reduce performance due to host/device transfers.
offload_outputs_to_cpu = true

# Number of input sequences to process in parallel (0 = auto).
batch_size = 0 # auto

# Maximum batch size to try when automatically determining the optimal batch size.
max_batch_size = 128
# Number of input sequences to process in parallel.
# If an out-of-memory error occurs, the batch size is halved automatically.
batch_size = 128

# Maximum number of tokens to generate for each response.
max_response_length = 100
Expand Down
11 changes: 2 additions & 9 deletions src/heretic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,9 @@ class Settings(BaseSettings):
)

batch_size: int = Field(
default=0, # auto
description="Number of input sequences to process in parallel (0 = auto).",
)

max_batch_size: int = Field(
default=128,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

100 is a better default per #248.

description="Maximum batch size to try when automatically determining the optimal batch size.",
# When storing a settings object, the batch size is already fixed,
# either determined by the automatic mechanism or by explicit user choice.
exclude=True,
description="Number of input sequences to process in parallel. "
"If an out-of-memory error occurs, the batch size is halved automatically.",
)
Comment on lines 184 to 188

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

No problem. We're in the 2.0 development cycle, where breaking changes are expected.

Comment on lines 184 to 188
Comment on lines 184 to 188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Since the default value of batch_size has been changed to 128 and max_batch_size has been removed, please ensure that config.default.toml is updated accordingly to keep them in sync. If config.default.toml is left with batch_size = 0, any user running with the default configuration will have settings.batch_size set to 0, which will resolve to an initial batch size of 1 in Model.__init__ and severely degrade performance.

References
  1. When new settings are added in config.py, they should also be added to config.default.toml, set to their default value and with their description as a comment. The order of settings in config.default.toml should match that in config.py. (link)

Comment on lines 184 to 188

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The default value of batch_size has been changed to 128 and max_batch_size has been removed. To keep the configuration files in sync and adhere to the repository style guide, please update config.default.toml to reflect these changes (updating the default value of batch_size and removing max_batch_size).

References
  1. When settings are added or modified in config.py, they should also be updated in config.default.toml to match their default values and descriptions. (link)


max_response_length: int = Field(
Expand Down
51 changes: 0 additions & 51 deletions src/heretic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,57 +386,6 @@ def run():
bad_prompts = load_prompts(settings, settings.bad_prompts)
print(f"* [bold]{len(bad_prompts)}[/] prompts loaded")

if settings.batch_size == 0:
print()
print("Determining optimal batch size...")

batch_size = 1
best_batch_size = -1
best_performance = -1

while batch_size <= settings.max_batch_size:
print(f"* Trying batch size [bold]{batch_size}[/]... ", end="")

prompts = good_prompts * math.ceil(batch_size / len(good_prompts))
prompts = prompts[:batch_size]

try:
# Warmup run to build the computation graph so that part isn't benchmarked.
model.get_responses(prompts)

start_time = time.perf_counter()
responses = model.get_responses(prompts)
end_time = time.perf_counter()
except Exception as error:
if batch_size == 1:
# Even a batch size of 1 already fails.
# We cannot recover from this.
raise

formatted = format_exception(error)
if "\n" in formatted:
print(f"[red]Failed:\n{formatted}[/]")
else:
print(f"[red]Failed ({formatted})[/]")

break

response_lengths = [
len(model.tokenizer.encode(response)) for response in responses
]
performance = sum(response_lengths) / (end_time - start_time)

print(f"[green]Ok[/] ([bold]{performance:.0f}[/] tokens/s)")

if performance > best_performance:
best_batch_size = batch_size
best_performance = performance

batch_size *= 2

settings.batch_size = best_batch_size
print(f"* Chosen batch size: [bold]{settings.batch_size}[/]")

if settings.response_prefix is None:
print()
print("Checking for common response prefix...")
Expand Down
76 changes: 48 additions & 28 deletions src/heretic/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors

import math
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from typing import Any, Type, cast
Expand Down Expand Up @@ -33,7 +34,7 @@

from .config import QuantizationMethod, RowNormalization, Settings
from .system import empty_cache
from .utils import Prompt, batchify, format_exception, print
from .utils import Prompt, format_exception, print


def get_model_class(
Expand Down Expand Up @@ -97,6 +98,10 @@ def __init__(self, settings: Settings):
self.tokenizer.padding_side = "left"

self.model = None # ty:ignore[invalid-assignment]
self._batch_sizes: dict[str, int] = {
key: settings.batch_size if settings.batch_size > 0 else 128
for key in ("responses", "residuals", "logprobs")
}
Comment on lines +101 to +104
Comment on lines +101 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

To ensure backward compatibility with existing user configurations that have batch_size = 0 (the previous default for 'auto'), it is safer to map 0 (or any non-positive value) to the new default of 128 instead of silently falling back to 1 via max(1, settings.batch_size). A batch size of 1 will cause extremely slow inference without any warning to the user.

Suggested change
self._batch_sizes: dict[str, int] = {
key: max(1, settings.batch_size)
for key in ("responses", "residuals", "logprobs")
}
self._batch_sizes: dict[str, int] = {
key: settings.batch_size if settings.batch_size > 0 else 128
for key in ("responses", "residuals", "logprobs")
}

Comment on lines +101 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In previous versions of Heretic, batch_size = 0 was the default value representing "auto" batch sizing. If an existing user upgrades and runs Heretic with their old config.toml containing batch_size = 0, max(1, settings.batch_size) will evaluate to 1. This will silently force a batch size of 1 for all operations, causing severe performance degradation without any warning.\n\nTo preserve backward compatibility and ensure a smooth upgrade path, please default to 128 if settings.batch_size is 0 or negative.

        self._batch_sizes: dict[str, int] = {\n            key: settings.batch_size if settings.batch_size > 0 else 128\n            for key in ('responses', 'residuals', 'logprobs')\n        }

self.max_memory = (
{int(k) if k.isdigit() else k: v for k, v in settings.max_memory.items()}
if settings.max_memory
Expand Down Expand Up @@ -672,20 +677,43 @@ def get_responses(
skip_special_tokens=skip_special_tokens,
)

def _batched(
self, prompts: list[Prompt], key: str, fn: Callable[[list[Prompt]], Any]
) -> list[Any]:
"""Run fn on prompt batches, halving the batch size on OOM."""
results = []
i = 0
while i < len(prompts):
n = self._batch_sizes[key]
try:
results.append(fn(prompts[i : i + n]))
i += n
except (torch.cuda.OutOfMemoryError, RuntimeError) as error:
# On non-CUDA platforms (MPS, XPU), OOM raises RuntimeError
# with an "out of memory" message rather than the typed exception.
if (
isinstance(error, RuntimeError)
and "out of memory" not in str(error).lower()
):
raise
if n == 1:
raise
self._batch_sizes[key] = n // 2
empty_cache()
Comment on lines +688 to +702
return results
Comment on lines +680 to +703

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

There are two key improvements we can make to the _batched helper method:

  1. Traceback Memory Leak / GC Delay: In Python, when an exception is caught, the active exception and its traceback are kept alive during the execution of the except block. This traceback holds references to the stack frame, keeping any local tensors allocated during the failed forward pass alive. Calling empty_cache() inside the except block will therefore not be able to reclaim that memory. Moving empty_cache() outside/after the except block ensures the exception is cleared and the memory is successfully freed before retrying.
  2. Non-CUDA Support: The codebase supports non-CUDA accelerators (like MPS and XPU) as seen in utils.py. Catching only torch.cuda.OutOfMemoryError means OOMs on other platforms (which typically raise RuntimeError with an "out of memory" message) won't be caught, causing crashes instead of adapting the batch size. We can catch both and check the error message for robustness.
    def _batched(
        self, prompts: list[Prompt], key: str, fn: Callable[[list[Prompt]], Any]
    ) -> list[Any]:
        """Run fn on prompt batches, halving the batch size on OOM."""
        results = []
        i = 0
        while i < len(prompts):
            n = self._batch_sizes[key]
            oom = False
            try:
                results.append(fn(prompts[i : i + n]))
                i += n
            except (torch.cuda.OutOfMemoryError, RuntimeError) as error:
                if isinstance(error, RuntimeError) and "out of memory" not in str(error):
                    raise
                if n == 1:
                    raise
                self._batch_sizes[key] = n // 2
                oom = True
            if oom:
                empty_cache()
        return results


def get_responses_batched(
self,
prompts: list[Prompt],
skip_special_tokens: bool = False,
) -> list[str]:
responses = []

for batch in batchify(prompts, self.settings.batch_size):
for response in self.get_responses(
batch,
skip_special_tokens=skip_special_tokens,
):
responses.append(response)

for batch in self._batched(
prompts,
"responses",
lambda b: self.get_responses(b, skip_special_tokens=skip_special_tokens),
):
responses.extend(batch)
return responses

def get_residuals(self, prompts: list[Prompt]) -> Tensor:
Expand Down Expand Up @@ -741,12 +769,10 @@ def get_residuals(self, prompts: list[Prompt]) -> Tensor:
return residuals

def get_residuals_batched(self, prompts: list[Prompt]) -> Tensor:
residuals = []

for batch in batchify(prompts, self.settings.batch_size):
residuals.append(self.get_residuals(batch))

return torch.cat(residuals, dim=0)
return torch.cat(
self._batched(prompts, "residuals", self.get_residuals),
dim=0,
)

def get_residuals_mean(self, prompts: list[Prompt]) -> Tensor:
if not prompts:
Expand All @@ -755,18 +781,14 @@ def get_residuals_mean(self, prompts: list[Prompt]) -> Tensor:
running_sum = None
total_count = 0

for batch in batchify(prompts, self.settings.batch_size):
batch_residuals = self.get_residuals(batch)

for batch in self._batched(prompts, "residuals", self.get_residuals):
# Accumulate in high precision on CPU to reduce peak VRAM usage.
batch_sum = batch_residuals.sum(dim=0, dtype=torch.float64).cpu()

batch_sum = batch.sum(dim=0, dtype=torch.float64).cpu()
if running_sum is None:
running_sum = batch_sum
else:
running_sum += batch_sum

total_count += batch_residuals.shape[0]
total_count += batch.shape[0]

assert running_sum is not None

Expand Down Expand Up @@ -806,12 +828,10 @@ def get_logprobs(self, prompts: list[Prompt]) -> Tensor:
return logprobs

def get_logprobs_batched(self, prompts: list[Prompt]) -> Tensor:
logprobs = []

for batch in batchify(prompts, self.settings.batch_size):
logprobs.append(self.get_logprobs(batch))

return torch.cat(logprobs, dim=0)
return torch.cat(
self._batched(prompts, "logprobs", self.get_logprobs),
dim=0,
)

def stream_chat_response(self, chat: list[dict[str, str]]) -> str:
# This cast is valid because str is the return type
Expand Down