Skip to content

Studio backend performance: five superlinear paths in the routes and data layer - #8499

Open
danielhanchen wants to merge 9 commits into
mainfrom
studio/routes-data-perf
Open

Studio backend performance: five superlinear paths in the routes and data layer#8499
danielhanchen wants to merge 9 commits into
mainfrom
studio/routes-data-perf

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

One of a series of Studio backend performance PRs. This one is the routes and
data layer: five paths that were superlinear in the size of their input, plus
one that decoded a whole vocabulary in order to throw it away.

Every change is a pure algorithmic rewrite of an existing rule. No behaviour,
no ordering, no error type, no error text and no log line changes.

Before

Path File Cost
GGUF header parse read, UTF-8 decoded and retained all 128k to 256k vocabulary entries, then kept the few hundred delimiter-shaped ones core/inference/llama_cpp.py 82.4 ms per parse
Tool result id resolution walked the history backwards from every result and rescanned each assistant's tool_calls, so n calls followed by n results was O(n^2) models/inference.py 216 ms per request at 2000 results
Context-overflow eviction copied the middle groups and dropped them with pop(0), shifting every remaining element per drop routes/inference.py 1.157 s at 32k messages
Diffusion dataset upload scanned every already-accepted filename for each incoming file, four times over, for exact, stem and case collisions routes/training.py 1.185 s for a 1000-file batch
check_url_access re-normalised the entire website policy for every URL, IDNA-encoding up to 200 domains each time core/inference/web_access_policy.py 28.6 ms per restricted search

After

Each one keeps the identical rule and changes only how it is looked up.

  • GGUF vocabulary. Read the length and the first byte, seek past the body when
    that byte is neither < nor [. The shape filter accepts nothing else, and
    UTF-8 is self-synchronising so no multi-byte character starts with either byte.
  • Tool result ids. The backward walk only ever looked inside the current
    user-delimited segment, so a single forward pass with an index answers the same
    question. Explicit ids use a per-segment map of the newest assistant call per
    id. Missing ids draw from a stack of assistants that still have an unclaimed
    call, each holding its remaining indexes in order plus the same indexes
    bucketed by function name, preserving the name-match-else-first-remaining
    order exactly. Requests whose tool results all carry ids now return before
    either pass, since the first pass only ever fed the second.
  • Overflow eviction. A cursor over the existing slice, no copy and no shifting.
  • Upload validation. Three per-request indexes. They keep insertion order, so
    each error message still names the same earlier file the linear scans named.
  • Website policy. normalize_domain is a pure function of its argument, so an
    all-str domain list memoises on an immutable tuple. Every public call still
    returns fresh dicts and lists.

Measured on this machine, minimum of repeated timings, CPU only:

Case Before After Speedup
GGUF header, 256k-token vocabulary 82.410 ms 44.338 ms 1.86x
Tool ids, 250 results, explicit 3.713 ms 0.566 ms 6.56x
Tool ids, 500 results, explicit 13.944 ms 1.130 ms 12.34x
Tool ids, 2000 results, explicit 215.917 ms 4.801 ms 44.97x
Tool ids, 500 results, ids missing 12.937 ms 1.607 ms 8.05x
Tool ids, 2000 results, ids missing 207.566 ms 7.077 ms 29.33x
Overflow truncation, 4k messages 23.384 ms 11.438 ms 2.04x
Overflow truncation, 16k messages 308.211 ms 44.079 ms 6.99x
Overflow truncation, 32k messages 1157.057 ms 89.598 ms 12.91x
Upload validation, 250 files 85.799 ms 12.827 ms 6.69x
Upload validation, 500 files 304.851 ms 21.760 ms 14.01x
Upload validation, 999 files 1185.300 ms 38.974 ms 30.41x
Website policy, 5 + 5 domains, 20 URLs 1.665 ms 0.369 ms 4.51x
Website policy, 100 + 100 domains, 20 URLs 28.564 ms 0.692 ms 41.30x

The upload figures stop at 999 files because Starlette caps one multipart
request at max_files=1000, so that is the largest batch this route can receive.

Controls, so the small common case is not paying for the large one. A request
validation costs 3 to 37 microseconds here:

Control Before After
1 user message 3.961 us 3.493 us
10-message chat, no tools 10.576 us 9.496 us
50-message chat, no tools 36.231 us 34.987 us
10 messages, 4 tool calls, ids present 11.725 us 8.533 us
10 messages, 4 tool calls, ids missing 12.939 us 14.402 us
Overflow truncation, 1000 messages 2.682 ms 2.661 ms

The one case that gets slower is a short history whose tool results are missing
their ids: building the index costs about 1.5 us more than the naive scan does at
n=4. That is the only regression I measured, and it is the same path that saves
205 ms at 2000 results.

How output equivalence was verified

Not by inspection. For each change I ran a differential harness that drives the
real code over a wide corpus, once against a pristine copy of origin/main and
once against this branch, and compares the recorded results byte for byte. The
emitted log text is compared the same way, with timestamps stripped, so a
changed or missing log line fails the check too. Every corpus includes empty,
unicode, malformed, oversized and adversarial inputs, and every harness applies
each operation twice to confirm idempotency is unchanged.

Change Corpus Result
GGUF vocabulary 33 synthetic headers: every possible leading byte, invalid UTF-8, lone surrogates, zero-length tokens, unicode delimiters, shape lookalikes, a vocabulary followed by further keys so a wrong stream position cannot hide, truncation at 20 offsets, and a length field overrunning the file by 1 TB. Compares all 22 parsed metadata attributes, parsed twice each identical
Tool result ids 12,012 histories, 292,742 messages, 208,677 tool results, 37,013 synthesised ids: duplicate ids within and across assistants, user and system boundaries, name matches, drained assistants, non-str and falsy ids, unhashable ids, 5000-character ids and names, orphan results. Compares the resolved id of every message plus a full revalidation of the result identical
Overflow truncation 3,016 message lists, 46,052 messages dropped: empty, single, all-system, all-tool, unserialisable content, ratios from -0.5 to 1.5, plus the second truncation of every result identical
Upload validation 2,032 batch uploads, each posted twice so the second sees the first on disk, driven through the real route. All four rejection branches exercised: 425 bad extension, 333 exact duplicate, 303 case variant, 274 sidecar stem. Run with the filesystem case-sensitivity answer forced both ways, since a case-sensitive host never reaches the third check. Compares status, response body and folder contents identical
Website policy 5,044 policies, 181,584 observations: non-str and unhashable entries, over-limit lists, unknown fields, IDNA, IPv4, IPv6, bracketed hosts, non-canonical numeric hosts, control characters, plus a poisoning probe that mutates a returned list and re-reads the policy identical

Test suite, python3 -m pytest tests/ -q -p no:randomly in studio/backend:

  • base 14c6ce31d: 343 failed, 21585 passed, 85 errors
  • this branch: 182 failed, 21746 passed, 85 errors
  • new failures introduced by this branch: none. The patched failure set is a
    strict subset of the base one.

The 161 that differ are cross-file interference, not a fix: they are GPU, VRAM
and tensor-parallel tests unrelated to these five files, and
test_metal_paravirtual_guard.py, test_mtp_vram_budget.py and
test_tensor_parallel.py all pass 397/397 in isolation on both the base and this
branch. The remaining failures and all 85 collection errors are environmental on
this host: no GPU, and fastmcp, pymupdf and sqlite_vec are absent.

The 13 test files that cover these paths directly give the identical result on
both trees: 1075 passed, and the same single pre-existing failure
(test_every_shipped_video_family_resolves_on_this_diffusers, a diffusers
version check).

Considered and dropped

  • Caching load_inference_config on the /api/inference/status poll. The
    endpoint reparses YAML on every request, which I measured at 3.575 ms per call,
    or roughly 43 ms of CPU per minute per open client. Dropped: the function logs
    a line per call, so caching it would silently remove log output, and it reads
    from disk on every call, so memoising it asserts an invariance that nothing in
    the code guarantees. The saving does not justify either.
  • Precomputing the SSE envelope for content and reasoning deltas. This means
    hand-rolling a second JSON serializer beside the ChatCompletionChunk model.
    Byte equality holds today, but nothing keeps the two in step, and no test
    patches that seam, so a future field on the model would diverge silently.
  • Reusing the already-decoded chunk in the SSE monitor. Depends on an
    identity check between the raw and emitted line to decide which path is safe,
    and it changes which branch the monitor takes for every relayed chunk.
  • Skipping the json.loads fallback on lines starting with data:. Changes
    an exception-driven control path for a per-line saving.
  • Adding a stream_deltas mode to generate_chat_completion. Changes the
    contract of a public generator and rewires three streaming routes. Too large a
    blast radius for this PR.

danielhanchen and others added 6 commits August 11, 2026 20:53
_gguf_read_array_value read, UTF-8 decoded and retained every entry of
tokenizer.ggml.tokens before handing the list to delimiter_shaped_tokens, which
keeps only "<...>" and "[...]" shapes. A vocabulary is 128k to 256k entries and a
few hundred survive.

Read the length and the first byte, then seek past the body when that byte is
neither "<" nor "[". UTF-8 is self-synchronising, so no multi-byte character
begins with either, and the shape filter provably rejects everything else.

A 256k-token header parses in 44.3 ms instead of 82.4 ms.
_resolve_missing_tool_call_ids walked the history backwards from every tool
result and rescanned each assistant's tool_calls, so one assistant with n calls
followed by n results was O(n^2). At 500 results that was 13.9 ms of request
validation, at 2000 it was 216 ms.

The backward walk only ever looked inside the current user-delimited segment, so
one forward pass with an index answers the same question. Explicit ids resolve
through a per-segment map of the newest assistant call per id. Missing ids draw
from a stack of the assistants that still have an unclaimed call, each holding
its remaining call indexes in order plus the same indexes bucketed by function
name, so the name-match-else-first-remaining rule keeps its exact order.

Requests where every tool result already carries its id now return before either
pass, since the first pass only ever fed the second.

500 results: 13.9 ms to 1.1 ms with explicit ids, 12.9 ms to 1.6 ms without.
_truncate_middle_messages copied the middle groups and dropped them with
pop(0), which shifts every remaining element, so evicting k of n groups cost
O(n*k). A cursor over the existing slice is enough and needs no copy.

32k messages truncate in 89.6 ms instead of 1.157 s.
Diffusion dataset upload validated each incoming filename by scanning every
filename already accepted in the batch: once for an exact duplicate, once for a
sidecar-stem collision, and twice more for a case variant. That is O(N^2) over a
batch, and Starlette accepts up to 1000 files in one multipart request.

Three indexes rebuilt per request answer all of it with hash lookups. The
per-casefold and per-stem entries keep insertion order, so each error message
still names the same earlier file the linear scans named.

A 1000-file batch validates in 39.0 ms instead of 1.185 s.
check_url_access re-normalises the whole policy for every URL it checks, and a
restricted web search checks up to 20 results against lists of up to 100 domains
each, lowercasing, IP-parsing and IDNA-encoding every one of them each time.

normalize_domain is a pure function of its argument, so an all-str domain list
memoises safely on an immutable tuple. Every public call still builds fresh
dicts and lists, and a list that is not exactly all str stays on the original
path so its error message keeps the original repr.

20 URLs against a 100 plus 100 domain policy: 0.69 ms instead of 28.6 ms.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75ea099df1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return ascii_domain


@lru_cache(maxsize = 256)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound raw domain strings before caching

An authenticated caller to /api/chat/research-runs can supply valid domains with arbitrarily large raw representations: Python's IDNA codec removes characters such as U+00AD, so "a" + "\u00ad" * N + ".com" normalizes to a.com and passes the 253-byte normalized-length check. This cache retains the original tuple as its key, allowing up to 256 large request-derived strings to remain resident and making repeated otherwise-valid policies capable of exhausting the backend's memory; reject/cache-bypass oversized raw inputs or avoid retaining the raw tuple.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in fc77b0b. "a" + "\u00ad" * 100000 + ".com" does normalise to a.com here, so the raw string was becoming an lru_cache key and staying resident for the life of the process.

The cache is now skipped for any raw entry longer than the maximum normalised domain (253), so those normalise on the uncached path. Rejecting them would have been a behaviour change, and this keeps the output byte-identical: the differential corpus for this file still reports RESULTS IDENTICAL against the pre-branch tree, including the padded cases and their ValueError messages, and the 100x100 policy benchmark is unchanged at 0.75 ms versus 26.8 ms before the branch. Regression test in test_oversized_raw_domains_normalize_without_entering_the_cache.

Nameprep deletes characters such as U+00AD, so an arbitrarily long raw string
can normalise to a short valid domain and pass the 253-byte check. The memo key
is the caller's raw tuple, so a request-sized string would stay resident for the
life of the process. Entries longer than the maximum normalised domain now take
the uncached path, which normalises identically.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: fc77b0b08f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

Ran this on a staging copy rather than adding to the org queue. Green: Unsloth API CI, Windows API, GGUF (Linux/Mac/Windows), Windows UI, Startup profile, Local Agent Guides.

Two reds, neither this branch:

Core. unsloth_zoo @ main full pytest, failing test_gemma4_dtype_drift_guards against upstream transformers. A different component; this PR only touches the Studio backend.

Mac Studio UI + API + Update CI. A Playwright TargetClosedError browser crash in the update-banner layout test. This branch touches no frontend.

The differential harnesses are the real check here, and all five report identical results and identical log text against a pristine base tree.

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