Skip to content

perf(serializer): skip the isinstance chain for plain JSON values - #2023

Merged
hassiebp merged 2 commits into
langfuse:mainfrom
DavidTraina:perf/event-serializer-fast-path
Oct 20, 2026
Merged

hassiebp merged 2 commits into
langfuse:mainfrom
DavidTraina:perf/event-serializer-fast-path

Conversation

@DavidTraina

@DavidTraina DavidTraina commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

The SDK serializes every observation's input and output on the caller's thread with json.dumps(obj, cls=EventSerializer). Metadata values go through the same encoder. In async services that trace large LLM payloads, this runs on the event loop.

EventSerializer.encode first walks the whole object through default() and only then hands the result to the C encoder. Every str, int, float, dict and list in the payload went through the isinstance chain in _default_inner before reaching its branch. On a 42 KB chat completion request, that walk took 6 to 8 times as long as json.dumps itself.

Changes, all in langfuse/_utils/serializer.py:

  • default() returns exact str, None, bool, JS-safe int and finite float values before the depth accounting. _default_inner returns these same objects unchanged. Ints outside the JS-safe range and NaN/Infinity still take the existing path, so they still become strings, including the fallback for ints too large for str().
  • _default_inner() walks exact dict, list and tuple values before the other checks, with the same depth limit and the same comprehensions as the existing branches. None of the earlier checks can match these exact types.
  • The checks use type(x) is ..., so subclasses such as str and int enums, numpy scalars, OrderedDict and namedtuples, and every other type, take the existing path in the existing order.
  • The JS-safe bounds are module constants shared with is_js_safe_integer.

The output is byte-for-byte unchanged for every input. That includes dict key conversion, depth truncation ("<dict>", "<list>", "<tuple>"), circular references, the seen set and both exception fallbacks. The separators and ensure_ascii settings only affect the C encoder stage, which is untouched.

Two internal details do change. Plain values no longer add a _default_inner stack frame, so a call made within a few frames of the recursion limit can now succeed where main fell back to the not-serializable marker. An EventSerializer subclass that overrides _default_inner or is_js_safe_integer no longer sees exact plain scalars. The module is private.

Benchmark

Thread CPU time per _serialize() call, which is what create_span_attributes and create_generation_attributes run for input and output. Each value is the median over 6 runs per checkout, alternating which checkout runs first. Environment: Apple M5 Max, pydantic 2.14.0. The machine had other load, so absolute numbers are somewhat inflated. The output hash matched main for every payload.

Python 3.13.15:

Payload Size main (us) this PR (us) change json.dumps (us) change on 3.11 change on 3.14
chat completion request, 61 messages, 8 tools 42.3 KB 619.5 138.0 -78% 71.3 -71% -78%
nested tool calls 27.3 KB 1498.7 295.4 -80% 121.1 -74% -82%
60 pydantic messages 17.1 KB 517.8 226.2 -56% n/a -51% -56%
1536 floats 31.9 KB 950.9 410.2 -57% 337.3 -54% -58%
5000 ints 57.0 KB 2021.0 376.4 -81% 146.2 -78% -83%
@observe args/kwargs 1.2 KB 11.6 4.1 -65% 2.2 -60% -65%
small message dict 0.1 KB 3.5 1.5 -58% 0.6 -54% -59%
2000 ints above 2**53 46.0 KB 902.0 1040.8 +15% n/a +15% +11%
500 UUIDs 20.0 KB 283.8 307.5 +8% n/a +7% +8%
500 datetimes 12.0 KB 352.7 381.3 +8% n/a +8% +5%

The last three rows are the trade-off. A value that misses the fast path, such as a datetime, a UUID or an int above 253, now pays a few extra type checks first, about 35 to 70 ns depending on the Python version. A list made only of such values gets slower: 5 to 8% for datetimes and UUIDs, and 11 to 15% for ints above 253. The cheaper a value is to convert, the larger the relative cost, so a list made only of NaN and Infinity is about 25 to 35% slower. Containers that mix such values with plain ones still get faster, because their plain values take the fast path.

Considered and not done:

  • Checking the whole tree first and passing the original object to the C encoder when it is all plain. That was up to 26% faster than this PR on dict-heavy plain payloads. When a non-plain value such as a datetime comes late in the payload, the plain part is walked twice, and it was 23 to 31% slower.
  • Inlining the scalar checks into the container loops instead of calling default(). That was up to 14% faster on plain payloads, mostly lists of ints, and 3 to 19% slower on lists of values that miss the fast path. I kept the smaller diff.
  • orjson. Its separators, escaping, large-int, NaN and non-str-key handling all differ, so it would change the output.
Benchmark script

Run it from outside the repo with PYTHONPATH=<checkout> python bench_serializer.py, so langfuse is imported from that checkout.

"""Caller-thread cost of serializing observation input and output.

Prints one JSON line per payload shape with the median time per _serialize()
call in microseconds, the output size, and a hash of the output. Run it once
per checkout, from outside the repo, e.g.
PYTHONPATH=<checkout> python bench_serializer.py
"""

import datetime as dt
import gc
import hashlib
import json
import random
import statistics
import time
import uuid

from pydantic import BaseModel

from langfuse._client.attributes import _serialize

rng = random.Random(0)
WORDS = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod".split()


def text(n_words):
    return " ".join(rng.choice(WORDS) for _ in range(n_words))


def chat_request():
    tool_params = {
        "type": "object",
        "properties": {
            f"arg_{j}": {"type": "string", "description": text(12)} for j in range(6)
        },
        "required": ["arg_0", "arg_1"],
    }
    tools = [
        {
            "type": "function",
            "function": {
                "name": f"tool_{i}",
                "description": text(30),
                "parameters": tool_params,
            },
        }
        for i in range(8)
    ]
    messages = [{"role": "system", "content": text(400)}]
    for i in range(15):
        calls = [
            {
                "id": f"call_{i}_{k}",
                "type": "function",
                "function": {
                    "name": f"tool_{k}",
                    "arguments": json.dumps({"arg_0": text(8)}),
                },
            }
            for k in range(2)
        ]
        messages.append({"role": "user", "content": text(120)})
        messages.append({"role": "assistant", "content": None, "tool_calls": calls})
        messages += [
            {"role": "tool", "tool_call_id": c["id"], "content": text(60)}
            for c in calls
        ]
    return {
        "model": "gpt-4o",
        "messages": messages,
        "tools": tools,
        "temperature": 0.2,
        "max_tokens": 4096,
        "stream": False,
    }


def nested_tool_calls(depth=4):
    if depth == 0:
        return {
            "id": rng.randrange(10**6),
            "score": rng.random(),
            "ok": True,
            "label": text(3),
            "note": None,
        }
    return {
        "step": depth,
        "input": {"query": text(6), "limit": 10, "filters": [text(1), text(1)]},
        "children": [nested_tool_calls(depth - 1) for _ in range(3)],
    }


class Function(BaseModel):
    name: str
    arguments: str


class ToolCall(BaseModel):
    id: str
    function: Function


class Message(BaseModel):
    role: str
    content: str | None = None
    tool_calls: list[ToolCall] | None = None


def pydantic_messages():
    messages = []
    for i in range(30):
        messages.append(Message(role="user", content=text(60)))
        call = ToolCall(
            id=f"call_{i}", function=Function(name="lookup", arguments='{"q": "x"}')
        )
        messages.append(Message(role="assistant", tool_calls=[call]))
    return messages


SHAPES = {
    "chat completion request": chat_request(),
    "nested tool calls": {"trace": [nested_tool_calls() for _ in range(2)]},
    "60 pydantic messages": pydantic_messages(),
    "1536 floats": [rng.uniform(-1, 1) for _ in range(1536)],
    "5000 ints": [rng.randrange(-(10**9), 10**9) for _ in range(5000)],
    "@observe args/kwargs": {
        "args": ("user-1", {"question": text(40), "history": [text(20)] * 5}),
        "kwargs": {"top_k": 5},
    },
    "2000 ints above 2**53": [1_760_000_000_000_000_000 + i for i in range(2000)],
    "500 UUIDs": [uuid.UUID(int=i) for i in range(500)],
    "500 datetimes": [
        dt.datetime(2024, 1, 1, tzinfo=dt.timezone.utc) + dt.timedelta(seconds=i)
        for i in range(500)
    ],
    "small message dict": {"role": "user", "content": "What is the capital of France?"},
}

ROUNDS = 30
clock = time.thread_time_ns


def measure(fn):
    start = clock()
    for _ in range(20):
        fn()
    loops = max(1, int(4e6 / max(1, (clock() - start) / 20)))
    timings = []
    for _ in range(ROUNDS):
        gc.collect()
        start = clock()
        for _ in range(loops):
            fn()
        timings.append((clock() - start) / loops / 1000)
    return statistics.median(timings)


for name, payload in SHAPES.items():
    out = _serialize(payload)
    row = {
        "shape": name,
        "bytes": len(out),
        "sha256": hashlib.sha256(out.encode()).hexdigest()[:12],
        "serialize_us": round(measure(lambda: _serialize(payload)), 2),
    }
    try:
        json.dumps(payload)
        row["json_dumps_us"] = round(measure(lambda: json.dumps(payload)), 2)
    except TypeError:
        pass
    print(json.dumps(row))

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactor
  • Documentation update
  • Tooling, CI, or repo maintenance

Verification

List the main commands you ran:

uv run --frozen ruff check .                                # All checks passed!
uv run --frozen ruff format --check .                       # 575 files already formatted
uv run --frozen mypy langfuse --no-error-summary            # no errors
uv run --frozen pytest tests/unit/test_serializer.py tests/unit/test_json.py  # 58 passed
uv run --frozen pytest -n auto --dist worksteal tests/unit  # 933 passed, 2 skipped

Output equivalence was checked with the script below. It compares the output and the debug log messages of this branch with a copy of the module from main. Besides plain values, the corpus covers most of the types the serializer special-cases: subclasses and enums of str, int and float, Streaming-named subclasses of the fast-pathed built-ins, ints at and beyond the JS-safe bounds and beyond the str() digit limit, NaN and infinities, non-str dict keys including keys that convert to unhashable values, sets, models, dataclasses, objects with __dict__ or __slots__, and circular containers and objects. It runs both encoder settings the SDK uses, with _MAX_DEPTH set to 1, 2, 3, 5 and 20. python fuzz_equivalence.py 20000 runs 102,900 cases, and it found no difference on Python 3.11.15, 3.13.15 or 3.14.5.

As a check on the script itself, it fails on each of these deliberate bugs in the fast path: no JS-safe range check, no isfinite check, an off-by-one depth check, no depth check for lists and tuples, dict keys not passed through default(), and isinstance instead of the exact type checks for str, int, float or the containers.

Test changes:

  • test_nested_containers_are_truncated_at_depth_boundary replaces the dict-only depth test and adds list, tuple and circular-list cases. Dropping the depth check for lists and tuples fails only these new cases.
  • test_plain_containers_convert_nested_values pins the exact output for ints on both sides of the JS-safe bounds and for NaN and infinities inside plain dicts and lists.
  • test_builtin_subclasses_take_the_full_branch_chain checks that Streaming-named subclasses of str, int, float, dict, list and tuple still reach the existing Streaming branch. It fails if any of the exact type checks becomes isinstance.
  • All three pass on main too.

Not run: tests/e2e and tests/live_provider, which need a server and provider keys. The serialized output is unchanged, so they exercise nothing new here.

Equivalence script
"""Compare EventSerializer output and debug logs with the pre-change module.

Setup: git show main:langfuse/_utils/serializer.py > old_serializer.py
Usage: PYTHONPATH=<checkout> python fuzz_equivalence.py [cases_per_depth] [seed]
"""

import collections
import collections.abc
import dataclasses
import datetime as dt
import decimal
import enum
import json
import logging
import random
import sys
import uuid
from pathlib import Path

from pydantic import BaseModel, SecretStr

import old_serializer
from langfuse._utils import serializer as new_serializer

OLD, NEW = old_serializer.EventSerializer, new_serializer.EventSerializer


class StrE(str, enum.Enum):
    A = "a"


class IntE(enum.IntEnum):
    ONE = 1
    BIG = 2**60


class ValE(enum.Enum):
    NAN = float("nan")
    TUP = (1, 2)


class WeirdStrE(str, enum.Enum):  # str content differs from .value
    def __new__(cls, v):
        obj = str.__new__(cls, "content-" + v)
        obj._value_ = v
        return obj

    A = "a"


class StreamingStr(str):  # matches the "Streaming" branch
    def __str__(self):
        return "custom"


class StreamingInt(int):
    def __str__(self):
        return "custom"


class StreamingFloat(float):
    def __str__(self):
        return "custom"


class StreamingDict(dict):
    def __str__(self):
        return "custom"


class StreamingList(list):
    def __str__(self):
        return "custom"


class StreamingTuple(tuple):
    def __str__(self):
        return "custom"


@dataclasses.dataclass
class DictDC(dict):  # matches the dataclass branch before the dict branch
    a: int = 1


class MyStr(str): ...


class MyInt(int): ...


class MyFloat(float): ...


class MyDict(dict): ...


class MyList(list): ...


@dataclasses.dataclass
class DC:
    a: object
    b: float = float("inf")


class Model(BaseModel):
    x: float
    y: list = []
    s: SecretStr = SecretStr("k")


class Obj:
    def __init__(self, v):
        self.v = v


class Slots:
    __slots__ = ("p",)

    def __init__(self, p):
        self.p = p


NT = collections.namedtuple("NT", "a b")
UTC = dt.timezone.utc

# fmt: off
LEAVES = [
    "", "plain", "\u00e9", "\u65e5\u672c", "\U0001f600", 'q"b\\s', "c\n\x00",
    MyStr("s"), StrE.A, enum.StrEnum("SE", "X").X, WeirdStrE.A, StreamingStr("s"),
    0, 1, -1, 2**53 - 1, 2**53, -(2**53) + 1, -(2**53), 2**63, 10**5000,
    MyInt(5), MyInt(2**60), IntE.ONE, IntE.BIG, StreamingInt(4), True, False, None,
    0.0, -0.0, 1.5, 1e308, 5e-324, float("nan"), float("inf"), float("-inf"),
    MyFloat(2.5), MyFloat("nan"), b"b", b"\xff", bytearray(b"ba"),
    dt.datetime(2024, 1, 2, tzinfo=UTC), dt.date(2024, 5, 6), dt.timedelta(1),
    uuid.UUID(int=7), Path("/tmp/x"), decimal.Decimal("1.1"), complex(1, 2),
    ValueError("boom"), ValE.NAN, ValE.TUP, SecretStr("s"), range(3), Slots(1),
    StreamingFloat(1.5), StreamingDict(a=1), StreamingList([1]), StreamingTuple((1,)),
    DictDC(),
]
KEYS = [
    "k", MyStr("sk"), StrE.A, 1, 2**53, 10**5000, 1.5, float("nan"), True, None,
    IntE.ONE, ValE.TUP, (1, 2), frozenset({1}), dt.date(2024, 1, 1), uuid.UUID(int=3),
]
# fmt: on
# Set order must not depend on id(), since model_dump() rebuilds sets. NaN
# hashes by id() too.
SET_ITEMS = [x for x in LEAVES if type(x).__hash__ not in (None, object.__hash__) and x == x]


def circular(rng, depth):
    if rng.random() < 0.5:
        items = [gen(rng, depth)]
        items.append(items)
        return items
    d = {"v": gen(rng, depth)}
    d["self"] = d
    return d


def circular_objects(rng, depth):
    a, b = Obj(None), Obj(None)
    a.v, b.v = b, [a, gen(rng, depth)]
    return a


def shared(rng, depth):
    d = {"s": gen(rng, depth)}
    return [d, d, (d,)]


CONTAINERS = [
    lambda r, d, n: {
        (r.choice(KEYS) if r.random() < 0.25 else f"k{i}"): gen(r, d) for i in range(n)
    },
    lambda r, d, n: [gen(r, d) for _ in range(n)],
    lambda r, d, n: tuple(gen(r, d) for _ in range(n)),
    lambda r, d, n: {r.choice(SET_ITEMS) for _ in range(n)},
    lambda r, d, n: frozenset(r.choice(SET_ITEMS) for _ in range(n)),
    lambda r, d, n: collections.OrderedDict((f"o{i}", gen(r, d)) for i in range(n)),
    lambda r, d, n: collections.defaultdict(list, x=gen(r, d)),
    lambda r, d, n: collections.deque([gen(r, d)]),
    lambda r, d, n: NT(gen(r, d), gen(r, d)),
    lambda r, d, n: MyDict(a=gen(r, d)),
    lambda r, d, n: MyList([gen(r, d)]),
    lambda r, d, n: DC(gen(r, d)),
    lambda r, d, n: Model(x=r.choice([1.0, float("nan")]), y=[gen(r, d)]),
    lambda r, d, n: Obj(gen(r, d)),
    lambda r, d, n: circular(r, d),
    lambda r, d, n: circular_objects(r, d),
    lambda r, d, n: shared(r, d),
]
WEIGHTS = [10, 10, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]


def gen(rng, depth=0):
    if depth > 26 or rng.random() < 0.35:
        return rng.choice(LEAVES)
    make = rng.choices(CONTAINERS, WEIGHTS)[0]
    return make(rng, depth + 1, rng.randrange(5))


def chain(wrap, n, leaf):
    for _ in range(n):
        leaf = wrap(leaf)
    return leaf


class Capture(logging.Handler):
    def __init__(self, logger):
        super().__init__(logging.DEBUG)
        self.messages = []
        logger.addHandler(self)
        logger.setLevel(logging.DEBUG)
        logger.propagate = False

    def emit(self, record):
        self.messages.append(record.getMessage())


old_log, new_log = Capture(old_serializer.logger), Capture(new_serializer.logger)
SETTINGS = [{}, {"separators": (",", ":"), "ensure_ascii": False}]


def check(obj, label):
    for kwargs in SETTINGS:
        old_log.messages.clear()
        new_log.messages.clear()
        old = json.dumps(obj, cls=OLD, **kwargs)
        new = json.dumps(obj, cls=NEW, **kwargs)
        if old != new:
            i = next(i for i, (a, b) in enumerate(zip(old, new)) if a != b)
            raise AssertionError(
                f"{label} {kwargs}\nold=...{old[i - 150 : i + 150]}\nnew=...{new[i - 150 : i + 150]}"
            )
        assert old_log.messages == new_log.messages, f"{label}: logs differ"


def main():
    per_depth = int(sys.argv[1]) if len(sys.argv) > 1 else 4000
    seed = int(sys.argv[2]) if len(sys.argv) > 2 else 0
    count = 0
    for value in LEAVES + KEYS:
        objs = [value, [value], {"k": value}]
        if isinstance(value, collections.abc.Hashable):
            objs.append({value: 1})
        for obj in objs:
            check(obj, type(value).__name__)
            count += 1
    for max_depth in (1, 2, 3, 5, 20):
        OLD._MAX_DEPTH = NEW._MAX_DEPTH = max_depth
        for wrap in (lambda x: {"c": x}, lambda x: [x], lambda x: (x,), lambda x: [{"m": (x,)}]):
            for n in range(26):
                for leaf in ("x", float("nan"), 2**60, StrE.A, Obj(1)):
                    check(chain(wrap, n, leaf), f"chain n={n} max_depth={max_depth}")
                    count += 1
        rng = random.Random(seed + max_depth)
        for i in range(per_depth):
            check(gen(rng), f"random seed={seed} max_depth={max_depth} i={i}")
            count += 1
    print(f"{count} cases identical (output and debug logs, {len(SETTINGS)} encoder settings)")


main()

Checklist

  • I self-reviewed the diff using code_review.md.
  • I added or updated tests for behavior changes.
  • I updated docs, examples, or .env.template if needed. (n/a, no public behavior change)
  • I did not hand-edit generated files; if generated files changed, I used the upstream regeneration path.
  • I did not commit secrets or credentials.

RetriggerView in GreptileConfidence Score: 4/5

The PR appears safe to merge, with a non-blocking request to add subclass regression tests.

Summary

Speeds up EventSerializer by returning plain scalar values early and moving exact built-in containers ahead of the other checks.

  • Plain JSON payloads serialize with fewer type checks.

Reviews (1) · Last reviewed commit: "perf(serializer): skip the isinstance ch..." · Reviewed by Greptile

EventSerializer.encode walks the whole object through default() before
the C encoder runs, and every str, int, float, dict and list went through
the full isinstance chain. default() now returns exact str, None, bool,
JS-safe int and finite float values directly, and exact dicts, lists and
tuples are walked before the other checks, with the same depth limit.
Subclasses and all other types take the existing path, so the output is
unchanged.
@DavidTraina
DavidTraina marked this pull request as ready for review October 19, 2026 21:21

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Comment thread tests/unit/test_serializer.py Resolved
@hassiebp

Copy link
Copy Markdown
Collaborator

@claude review

@claude claude 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.

Code review found no issues

No high-confidence issues detected in this change.

@hassiebp
hassiebp merged commit 685dc88 into langfuse:main Oct 20, 2026
16 of 19 checks passed
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