Skip to content

perf(logging): serialize safe_dumps in a single pass on the hot path - #31711

Closed
yassin-berriai wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_safe_json_dumps_single_pass
Closed

perf(logging): serialize safe_dumps in a single pass on the hot path#31711
yassin-berriai wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_safe_json_dumps_single_pass

Conversation

@yassin-berriai

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

LIT-4105

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Follow-up to the OTel per-request import fix (#31707). After that fix, the remaining proxy-overhead gap versus v1.85.0 was profiled with single-worker cProfile, bucketing self-time per request by module. The dependency bumps are net neutral (starlette 1.x is actually faster), so the residual is litellm's own new per-request work, and the largest single slice is safe_json_dumps

safe_dumps serialized in two passes: a full recursive Python-level copy of the payload to strip NUL bytes and detect circular references, then json.dumps, which re-traverses the whole structure. The standard logging payload is serialized on every request, and the per-token cost and cache breakdown added in 1.91 grew that payload, so the redundant pass became the biggest single contributor to the residual overhead

This encodes the common case in a single pass and falls back to the existing sanitizer only when needed

Microbenchmark on a representative ~1.9KB standard-logging payload, 200k iterations, output asserted identical between old and new

reference (old): 18.16 us/call
new (fast path):  7.44 us/call
speedup: 2.44x  (59% faster)

Residual-gap attribution (single-worker cProfile, self-time ms/req, 1.85.0 -> patched 1.91), showing safe_json_dumps as the top litellm contributor and starlette as a net win

TOTAL self-time/req: 1.85.0=9.111ms  1.91=9.531ms  delta=+0.420ms (+4.6%)

litellm:logging+cost   +0.098   (safe_json_dumps is the bulk of this)
litellm:proxy          +0.098
dep:pydantic           +0.081
dep:fastapi            +0.048
dep:starlette          -0.119   (faster, despite the 0.50 -> 1.3 bump)

Full benchmark and attribution: https://gist.github.com/yassin-berriai/31ecf15a2ba6d4298c3f2924c734f1a3

This is one increment. The rest of the residual is genuinely new per-request feature work (lazy-feature route matching, agentic-loop gating, callback-capability checks) plus the pydantic validation it pulls in, with no further single hotspot, so the end-to-end throughput contribution of this PR alone is small and below load-test noise; the per-call serialization win is large and deterministic

Type

🚄 Infrastructure

Changes

safe_dumps now tries a single json.dumps pass with a default that mirrors the sanitizer's handling of pydantic models (model_dump), sets (sorted), and unencodable objects (string fallback). It falls back to the recursive sanitizer when the encoded output contains a NUL escape, a circular reference is hit, or a key json cannot encode. A caller-tightened max_depth always uses the sanitizer, so its truncation stays exact. The sanitizer is lifted from a per-call nested closure to a module-level _sanitize, which also avoids rebuilding it on every call

Behavior is locked by a 600-case fuzz test that compares the new path against a verbatim copy of the previous sanitizer across nested dicts, lists, tuples, sets, pydantic models, NUL bytes, and the literal backslash-u-0000 text edge case, plus the existing suite (max_depth truncation, NUL stripping, cycles, non-string keys, pydantic models). Dicts with keys json can stringify (int, bool, None) are now kept with their JSON key form instead of being silently dropped; keys json cannot encode still fall back and are dropped as before

safe_dumps built a full recursive copy of the payload to strip NUL bytes and
detect cycles, then json.dumps re-traversed it; two passes over every logged
object. The standard logging payload is serialized on every request, and the
per-token cost and cache breakdown added in 1.91 grew that payload, so this was
the largest single contributor to the residual proxy overhead versus v1.85.0.

Encode the common case (default depth budget, no NUL byte, no circular reference)
in a single json.dumps pass, falling back to the existing recursive sanitizer
when the output holds a NUL escape, a cycle is hit, or a key json cannot encode.
A caller-tightened max_depth always uses the sanitizer so its truncation stays
exact. Extracting the sanitizer to module scope also stops it being rebuilt as a
closure on every call. About 2.4x faster on a representative 1.9KB logging
payload, output unchanged.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR optimizes safe_dumps by attempting a single json.dumps pass with a custom default handler and only invoking the full recursive sanitizer when needed (NUL byte detected in output, circular reference, or encoding error). The inner _serialize closure is also lifted to a module-level _sanitize function to avoid per-call closure construction.

  • The fast path is gated on max_depth >= DEFAULT_MAX_RECURSE_DEPTH, so caller-tightened depths always use the sanitizer and preserve exact truncation behavior. A 600-iteration fuzz test validates byte-identical output against the previous sanitizer for realistic payloads.
  • Dicts with non-string primitive keys (int, bool, None) are now retained by the fast path instead of being silently dropped; this is a documented intentional behavior change. Existing tests for circular references, NUL stripping, pydantic models, and unserializable objects are unchanged and continue to pass.

Confidence Score: 5/5

Safe to merge; all existing contracts are preserved and the documented behavior change for non-string primitive dict keys is intentional and tested.

The fast path correctly falls back to the sanitizer for every meaningful edge case (NUL bytes, circular references, un-encodable keys). The only inconsistency found — frozenset serialized as a sorted list by the fast path but as a string by the sanitizer fallback — is an untested edge case that cannot be triggered in isolation and is inconsequential for real logging payloads. All existing tests pass, the fuzz test covers 600 mixed-type payloads, and the performance improvement is well-evidenced.

No files require special attention beyond the noted frozenset inconsistency in safe_json_dumps.py.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/safe_json_dumps.py Rewrites safe_dumps to attempt a single json.dumps pass and fall back to the recursive sanitizer only on NUL detection, circular reference, or encoding error. The inner _serialize closure is lifted to a module-level _sanitize function. Logic is sound for the common case; minor frozenset divergence between _json_default and _sanitize exists but is low-impact in practice.
tests/test_litellm/litellm_core_utils/test_safe_json_dumps.py Adds a 600-iteration fuzz test comparing the new fast path against a verbatim copy of the previous sanitizer, plus explicit tests for tightened max_depth and non-string primitive keys. Existing tests for circular references, null bytes, pydantic models, and unserializable objects are preserved unchanged.

Reviews (2): Last reviewed commit: "perf(logging): serialize safe_dumps in a..." | Re-trigger Greptile

Comment on lines +88 to +95
if max_depth >= DEFAULT_MAX_RECURSE_DEPTH:
try:
encoded = json.dumps(data, default=_json_default)
except (ValueError, TypeError, RecursionError):
pass
else:
# Fall back to string conversion for non-serializable objects.
try:
return strip_null_bytes(str(obj))
except Exception:
return "Unserializable Object"

safe_data = _serialize(data, set(), 0)
return json.dumps(safe_data, default=str)
if _NUL_ESCAPE not in encoded:
return encoded

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.

P1 Default depth bypassed
The fast path ignores the default max_depth unless json.dumps itself raises. For an acyclic payload deeper than DEFAULT_MAX_RECURSE_DEPTH but below the JSON encoder recursion limit, safe_dumps(data) now returns the full object instead of inserting "MaxDepthExceeded". The new test only covers caller-tightened depths, so the default depth contract is broken for deep logging payloads.

Artifacts

Repro: focused Python script constructing a deep acyclic payload and asserting default depth truncation

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: failing runtime output showing full deep serialization without MaxDepthExceeded

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

Closing this. Greptile's P1 is correct, and CI's test_default_max_depth caught the same thing: without a depth check the single pass silently drops the default max_depth truncation for deep acyclic payloads. Adding the required depth pre-scan to keep that contract reintroduces a full O(n) traversal, and benchmarking the corrected version shows it is net slower than the original two-pass implementation on payloads containing pydantic models (about 0.97x); the model serialization work simply moves into json.dumps while the pre-scan adds overhead. So the optimization is not viable, and the residual overhead is better treated as new-feature cost. Thanks @greptileai for the catch

@yassin-berriai
yassin-berriai deleted the litellm_safe_json_dumps_single_pass branch June 30, 2026 13:07
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for the thorough follow-up and benchmarking — that's exactly the right call. The depth pre-scan turning a net win into a net loss on pydantic-heavy payloads is a meaningful finding worth documenting here for anyone who revisits this later.

Closing is the right move given the correctness constraint and the benchmark outcome.

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