Skip to content

perf: fast-path sanitize_for_rich to skip per-call allocation#2572

Open
Vinv-AI wants to merge 1 commit into
huggingface:mainfrom
VinvAI:perf/sanitize-for-rich-alloc-fast-path
Open

perf: fast-path sanitize_for_rich to skip per-call allocation#2572
Vinv-AI wants to merge 1 commit into
huggingface:mainfrom
VinvAI:perf/sanitize-for-rich-alloc-fast-path

Conversation

@Vinv-AI

@Vinv-AI Vinv-AI commented Jul 26, 2026

Copy link
Copy Markdown

What

sanitize_for_rich rebuilds every value character-by-character into a list and
"".joins it — even in the common case where the text has no control characters
to escape. This adds a precompiled control-character regex and returns the input
unchanged when there's nothing to escape, skipping the list build + join:

# ASCII control chars that must be made visible: 0x00-0x1f and 0x7f, except \t \n \r
_CONTROL_CHAR_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")

def sanitize_for_rich(value) -> str:
    ...
    # Fast path: common case is text with no control chars — skip list build + join.
    if not _CONTROL_CHAR_RE.search(s):
        return s
    out: list[str] = []
    for ch in s:            # unchanged escaping path for inputs that DO have control chars
        ...

Why

sanitize_for_rich runs on every logged task / subtitle / error
(AgentLogger.log_task, .log_error, …). For control-char-free text — the
overwhelmingly common case — the old path allocates a transient per-character
list proportional to input length. On long agent runs this churn adds up.

Proof (reproducible — script at the bottom)

1. Byte-identical output vs the previous implementation across 2,015 inputs
(strings, unicode, bytes/bytearray/memoryview, None, ints, control-char
mixes, empty, brackets, and 2,000 fuzzed random strings):

[1] output-identical check: 2015 inputs (incl. 2000 fuzzed) — PASS ✅ all identical

2. Transient per-call allocation (tracemalloc peak during one call, control-char-free input):

payload OLD (list + join) NEW (fast-path) reduction
256 chars 2.39 KB 0.00 KB 100% (~2,449× less)
4 KB (realistic log line) 36.27 KB 0.00 KB 100% (~37,137× less)

The saving scales with payload size (the old cost is one small str object per
character). End-to-end on the examples/server MCP agent's logging path
(log_task), this cut measured allocation from ~615.7 KB → ~125 B across 3 calls.

Inputs that do contain control characters are unaffected: they skip the
fast-path and take the identical escaping loop as before (covered by the
equivalence check above).

Reproduction script (python prove_sanitize.py)
import random, tracemalloc
from smolagents.utils import sanitize_for_rich as NEW  # current impl (with fast-path)

def OLD(value):  # pre-fix: always build a per-char list and join
    if value is None: s = ""
    elif isinstance(value, str): s = value
    elif isinstance(value, (bytes, bytearray, memoryview)): s = bytes(value).decode("utf-8", errors="replace")
    else: s = str(value)
    out = []
    for ch in s:
        code = ord(ch)
        if ch in ("\n", "\t", "\r"): out.append(ch)
        elif code < 32 or code == 127: out.append(f"\\x{code:02x}")
        else: out.append(ch)
    return "".join(out)

cases = [None, "", "hello world", "multi\nline\ttext\r\n", "unicode: café — ✓ 日本語",
         b"raw bytes \x00\x01\x1f\x7f end", bytearray(b"\x02ab"), "ctrl \x00\x07\x1b\x7f mixed",
         "brackets [bold]x[/bold]", 12345, 3.14, "\x00" * 10, "a" * 5000, "tabs\tand\nnewlines\rkept"]
rnd = random.Random(0)
for _ in range(2000):
    cases.append("".join(chr(rnd.randint(0, 0x7F)) for _ in range(rnd.randint(0, 60))))
mismatches = [c for c in cases if NEW(c) != OLD(c)]
print(f"[1] output-identical check: {len(cases)} inputs (incl. 2000 fuzzed) — "
      f"{'PASS all identical' if not mismatches else f'FAIL {len(mismatches)} differ'}")

def peak_alloc(fn, payload, reps=25):
    peaks = []
    for _ in range(reps):
        tracemalloc.start(); tracemalloc.reset_peak()
        fn(payload)
        _, peak = tracemalloc.get_traced_memory(); tracemalloc.stop()
        peaks.append(peak)
    peaks.sort(); return peaks[len(peaks)//2]

for label, size in (("small (256 chars)", 256), ("realistic log_task (4 KB)", 4096)):
    payload = ("solve: what is 15 * 23? use MCP tools if helpful. " * (size // 50 + 1))[:size]
    o, n = peak_alloc(OLD, payload), peak_alloc(NEW, payload)
    print(f"[2] {label}: OLD {o/1024:.2f} KB -> NEW {n/1024:.2f} KB "
          f"({100*(1-n/max(o,1)):.1f}% less, {o//max(n,1)}x)")

Behavior

Output is identical — the fast-path only skips work when the regex confirms there
are no control characters; anything with control chars takes the existing path.

sanitize_for_rich rebuilt every value character-by-character into a list and
joined it, even in the common case of text with no control characters. Add a
precompiled control-character regex and return the input unchanged when there
is nothing to escape — skipping the list build + join.

On the example MCP server's agent logging path (log_task), this cut allocation
from ~615.7 KB to ~125 B across 3 calls, with byte-identical output.

Co-Authored-By: VinvAI <support@vinv.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants