⚡ Skip set(obj.keys()) allocation in jsonable_encoder when include/exclude are not set#15476
Conversation
set(obj.keys()) allocation in jsonable_encoder when include/exclude are not set
There was a problem hiding this comment.
@VittoriaLanzo, thanks for your interest and efforts!
I agree this would improve the performance in some specific cases, but from what I can see, Sebastian tends not to touch jsonable_encoder if it's not necessary.
Before I can pass this to Sebastian for review, could you please:
- remove
scripts/bench_jsonable_encoder.py(you can share it in the comments, show how you tested the performance impact) - and address my comment regarding tests in
tests/test_jsonable_encoder.py
?
|
The speedup comes from two costs eliminated on every no-filter call (
On the hot path, the replacement is a single pointer comparison ( Benchmark script and methodologyMeasured with git fetch origin master
git worktree add /tmp/fastapi-before 622b6356 # fastapi/fastapi master 2026-05-06
git worktree add /tmp/fastapi-after fix/jsonable-encoder-lazy-allowed-keys
cp bench_jsonable_encoder.py /tmp/fastapi-before/
uv run --directory /tmp/fastapi-before python bench_jsonable_encoder.py
uv run --directory /tmp/fastapi-after python bench_jsonable_encoder.py
git worktree remove /tmp/fastapi-before
git worktree remove /tmp/fastapi-afterimport statistics, timeit
from typing import Any
from fastapi.encoders import jsonable_encoder
LARGE_ITEMS = [
{"id": i, "name": f"item-{i}", "values": list(range(25)),
"meta": {"active": True, "group": i % 10, "tag": f"t{i % 5}"}}
for i in range(300)
]
LARGE_METADATA = {
"source": "benchmark", "version": 1,
"flags": {"a": True, "b": False, "c": True},
"notes": ["x" * 50, "y" * 50, "z" * 50],
}
LARGE_PAYLOAD: dict[str, Any] = {"items": LARGE_ITEMS, "metadata": LARGE_METADATA}
SMALL_PAYLOAD: dict[str, Any] = {"name": "foo", "value": 123}
ROUNDS, ITERS = 20, 300
def bench(payload):
times = []
for _ in range(ROUNDS):
t = timeit.timeit(lambda: jsonable_encoder(payload), number=ITERS)
times.append(t / ITERS * 1e6)
return statistics.mean(times), statistics.stdev(times)
for label, payload in [("small dict (3 keys)", SMALL_PAYLOAD),
("large dict (300 items, nested)", LARGE_PAYLOAD)]:
mean, sd = bench(payload)
print(f"{label:<35} {mean:>8.2f} µs ±{sd:.2f}")
Python 3.12, FastAPI 0.136.1. |
f0a4a2c to
9ec5654
Compare
YuriiMotov
left a comment
There was a problem hiding this comment.
LGTM!
@VittoriaLanzo, thanks!
Passing this to Sebastian for the final review
jsonable_encoderbuildsallowed_keys = set(obj.keys())unconditionally on every dict branch entry, even when bothincludeandexcludeareNone. The common case where no filtering is needed. The set is then used only to confirm that every key passes a membership test it would always pass, making the allocation pure overhead. Becausejsonable_encoderrecurses into nested dict values, the cost multiplies with nesting depth and runs on every dict response in the application.Solution
Initialise
allowed_keys: set[Any] | None = Noneand wrap the set construction insideif include is not None or exclude is not None:. The loop condition becomesallowed_keys is None or key in allowed_keys. When both parameters areNone, no set is built and no membership test runs. When either is set, the existing intersection/subtraction logic is unchanged.Performance
Measured locally with
timeit(20 rounds × 300 iterations, mean reported). These are wall-time numbers on a single machineThe wall-time gain is proportionally larger on shallow dicts, where the set allocation is a greater fraction of total work, and accumulates across repeated calls on dicts of any size.
Testing
Added seven tests to
tests/test_jsonable_encoder.pycovering: all-keys returned whenincludeandexcludeare bothNone,includefiltering,excludefiltering, combinedinclude+exclude, nested dict with no filter, emptyincludereturns empty, emptyexcludereturns all keys. All pre-existing tests pass unchanged.Files modified
fastapi/encoders.pytests/test_jsonable_encoder.pyAgentic Pipeline diagram
flowchart TD %% Node Definitions START([START]) --> PE PE[F1.extractor<br/>confirms bug on main] PE -.->|ALREADY_IMPLEMENTED| HALT_IMPL([HALT: already fixed]) PE -->|PATTERN_EXTRACTED| IW IW[F1.issue-writer<br/>writes issue body] IW -.->|DUPLICATE_EXISTS| HALT_DUP([HALT: duplicate]) IW -->|ISSUE_READY| FILE FILE{{HITL check}} FILE --> CA CA[F1.author<br/>implement + tests · single pass] CA --> LOOP subgraph LOOP [Review loop · max 5 rounds] direction TB SR[F1.swe-reviewer<br/>blind to cold findings] CR[F1.cold-reviewer<br/>blind to swe findings] SE[F1.engineer<br/>fixes against both reports] SR --> SE CR --> SE SE -->|any_valid_findings=true · round N+1| SR SE -->|any_valid_findings=true · round N+1| CR end LOOP -->|any_valid_findings=false| CG LOOP -.->|round 5 · still findings| HITL_WARN([HITL<br/>review]) CG{F1.ci-gate} CG -->|RED| CA CG -->|GREEN| PRW PRW[F1.pr-writer<br/>references issue_number] PRW --> PRA PRA{F1.pr-adversary<br/>description-only loop} PRA -->|revisions| PRW PRA -->|PR_READY| LA LA{F1.license-auditor} LA -.->|BLOCK| HALT_COMP([HALT: compliance]) LA -->|PASS| HITL HITL{{HITL final rewiev }} HITL -.->|NO| CA HITL -->|YES| PUSH([git push · open PR]) %% Styling & Palette classDef default fill:#f9f9fb,stroke:#333,stroke-width:1px,color:#1a1a1a; classDef action fill:#e1f5fe,stroke:#01579b,stroke-width:2px; classDef critical fill:#fff4e5,stroke:#ff9800,stroke-width:2px; classDef halt fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5; classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; class PE,IW,CA,SR,CR,SE,PRW,PRA,LA action; class FILE,HITL critical; class HALT_IMPL,HALT_DUP,HALT_COMP halt; class PUSH success; style LOOP fill:#f1f3f4,stroke:#5f6368,stroke-width:2px,stroke-dasharray: 5 5 style HITL_WARN fill:#fffde7,stroke:#fbc02d,stroke-width:2px