Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Cache structs.fields() result for non-generic structs - #1005

Open
Siyet wants to merge 10 commits into
mainfrom
963-cache-fields
Open

Cache structs.fields() result for non-generic structs#1005
Siyet wants to merge 10 commits into
mainfrom
963-cache-fields

Conversation

@Siyet

@Siyet Siyet commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Reopening #997 (auto-closed when fork was deleted).

Summary

  • Cache the fields() result tuple on the struct class as __struct_field_info__
  • Avoids expensive get_class_annotations() call and FieldInfo object recreation on every invocation
  • Generic aliases (e.g. MyStruct[int]) are not cached since type parameters affect annotations

Closes #963

Benchmark (1M calls, 3 fields)

Before After
dataclasses.fields() 0.85s 0.85s
msgspec.structs.fields() ~7.5s (20x slower) 0.44s (2x faster)

Store the computed tuple of FieldInfo on the class as
__struct_field_info__ to avoid expensive get_class_annotations()
and FieldInfo recreation on every call. Generic aliases are not
cached since type parameters affect annotations.

Closes #963
Comment thread src/msgspec/structs.py Outdated

# Cache for non-generic structs
if not is_generic:
cls.__struct_field_info__ = result

@ofek ofek Apr 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Mutating the type as a result of inspection seems wrong to me. Is there no other way we can cache or otherwise close the gap in performance here? Also, does this work if the Struct is configured as frozen?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the review!

Let me address your points in order.

Frozen structs. Yes, this works. frozen=True sets tp_setattro = Struct_setattro_frozen on the struct class itself, which only blocks attribute assignment on instances. The cache write cls.__struct_field_info__ = result goes through type(cls).tp_setattro (i.e. StructMetaType's slot, inherited from PyType_Type), which isn't overridden and isn't affected by frozen. That said, you're right that it's not obvious from the diff, I just pushed an explicit test for frozen structs to make the guarantee visible.

Why mutation, and what alternatives I considered. The dominant cost in fields() is _get_class_annotations() (typing.get_type_hints()), which is what makes the difference between 0.44s and 7.5s on the benchmark. I looked at a few alternatives before landing on __struct_field_info__:

  • Module-level WeakKeyDictionary: keeps fields() non-mutating, but adds an extra lookup on every call and another global to reason about. Felt strictly worse.
  • Eager compute in StructMeta_new: fastest in theory, but breaks on forward references, which is exactly why the current implementation is lazy.
  • Caching only hints instead of the full tuple: still requires mutating the class, and we'd still rebuild FieldInfo objects on every call (a meaningful chunk of the remaining cost).

In the end I picked the option with the smallest surface and the biggest win, treating __struct_field_info__ as a conventional cache slot (similar to how CPython uses dunder names for lazily-populated metadata).

On "mutation feels wrong". I hear you and I agree it's not ideal aesthetically. I see two ways forward:

  1. Keep the current approach, document __struct_field_info__ as an internal cache slot, and add the frozen test. Pragmatic, minimal diff.
  2. Port fields() to C as a follow-up PR. StructMetaObject already holds everything we need (struct_encode_fields, struct_offsets, struct_types, struct_defaults), so for non-generic structs we can skip typing.get_type_hints entirely and build the FieldInfo tuple directly. This would likely beat even the cached Python version and fully sidesteps the mutation question.

Personally I lean toward option 2 (port to C): it gives the best perf, removes the class mutation, and fits naturally into the existing StructMetaObject C infrastructure. If you're on board, I'm happy to close this PR and open a new one with the C port. @jcrist, would love to hear your take as well, since this touches StructMeta design.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call on the mutation. I've reworked it: instead of writing __struct_field_info__ onto the class, the cache now lives in a module-level weakref.WeakKeyDictionary keyed on the struct type. The class is no longer touched at all, so inspection stays side-effect free, nothing shows up in dir()/vars(), and there's no risk of a subclass picking up a parent's cached tuple via MRO. Entries drop automatically when a class is collected (verified with dynamically-created defstruct classes). The frozen question is moot now since we never set an attribute on the instance or the class.

On the C-port idea I floated earlier: I dug into it and it doesn't pan out. FieldInfo.type has to return the resolved Python annotation (e.g. int | None, Annotated[int, Meta(...)], forward refs resolved). StructMetaObject doesn't store that, only the compiled TypeNode, which is lossy (e.g. Meta(gt=3) and Meta(ge=4) collapse to the same node). Rebuilding the annotation from a TypeNode would mean reimplementing typing.get_type_hints in C, and it would still need lazy resolution for forward refs. So I'm dropping that option; the side-table cache gives the same speedup without the mutation.

@Siyet
Siyet requested a review from jcrist April 10, 2026 07:32
@Siyet

Siyet commented Apr 10, 2026

Copy link
Copy Markdown
Contributor Author

The failing build job here is unrelated to this PR: it's the link checker tripping on the Pydantic docs redirect (docs.pydantic.dev/latest/pydantic.dev/docs/validation/...), which is fixed in #1008. Once #1008 lands and this branch is rebased, CI should go green.

@Siyet
Siyet requested a review from ofek April 11, 2026 18:04
@Siyet

Siyet commented Apr 21, 2026

Copy link
Copy Markdown
Contributor Author

@ofek @provinzkraut would appreciate your take here.

The blocker: this PR caches fields() by setting __struct_field_info__ on the class. @ofek flagged that mutating the type on inspection feels wrong.

Two paths:

  1. Ship as-is, document __struct_field_info__ as an internal cache slot (0.44s vs 7.5s on the bench, biggest win of the alternatives I tried).
  2. Close this, port fields() to C as a follow-up - StructMetaObject already has everything needed for non-generic structs, sidesteps the mutation concern.

Leaning toward 2 but wanted a second opinion before committing.

@Siyet
Siyet temporarily deployed to docs-preview June 15, 2026 21:56 — with GitHub Actions Inactive
@ofek
ofek temporarily deployed to docs-preview June 20, 2026 17:28 — with GitHub Actions Inactive
@ofek ofek mentioned this pull request Jun 20, 2026
@ofek

ofek commented Jun 20, 2026

Copy link
Copy Markdown
Member

The benchmarks didn't run (#1091) so I have to update the branch once more. Sorry for the noise!

@ofek
ofek temporarily deployed to docs-preview June 20, 2026 20:22 — with GitHub Actions Inactive
@codspeed-hq

codspeed-hq Bot commented Jun 20, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×3.4

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 6 improved benchmarks
✅ 133 untouched benchmarks
⏩ 135 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation test_fields[arm-complex_class] 119.2 µs 17.4 µs ×6.8
Simulation test_fields[arm-complex_instance] 118.6 µs 19.4 µs ×6.1
Simulation test_fields[arm-class] 104.2 µs 17.6 µs ×5.9
Simulation test_fields[arm-instance] 104.9 µs 19.5 µs ×5.4
Simulation test_decode_type[arm-msgpack-None] 760.6 µs 682.7 µs +11.4%
Simulation test_pickle_load[arm] 1.8 ms 1.6 ms +11.16%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing 963-cache-fields (c86ba9e) with main (f51f378)

Open in CodSpeed

Footnotes

  1. 135 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@provinzkraut

Copy link
Copy Markdown
Member

re benchmarks: Since this is aimed to improved performance, we should probably also have a benchmark that reflects this. Would also prevent future regressions. I've put up #1092 with some more benchmarks

@provinzkraut

Copy link
Copy Markdown
Member

@ofek @provinzkraut would appreciate your take here.

The blocker: this PR caches fields() by setting __struct_field_info__ on the class. @ofek flagged that mutating the type on inspection feels wrong.

Two paths:

1. Ship as-is, document `__struct_field_info__` as an internal cache slot (0.44s vs 7.5s on the bench, biggest win of the alternatives I tried).

2. Close this, port `fields()` to C as a follow-up - `StructMetaObject` already has everything needed for non-generic structs, sidesteps the mutation concern.

Leaning toward 2 but wanted a second opinion before committing.

@Siyet as I mentioned here, I believe we should do both; We don't have to recompute this, making it lazy and storing is is the right way. However, we also already have all the information internally, so it would make more sense to expose that from the C-side. I'd like to see how expensive that is performance wise. Maybe caching isn't needed at all.

@Siyet

Siyet commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@provinzkraut I've measured the C-side option. The catch: the C side doesn't actually retain the resolved annotations.

StructMetaObject stores field names, defaults and encode names (already exposed as __struct_fields__ etc.), but no types. The only type info C ever has is StructInfo's TypeNode array, which is (a) built lazily, only when the type is first used in a decoder/convert, and (b) a lossy compressed encoding - List[int]/Sequence[int] collapse into the same bits, Meta title/description are dropped - so it can't be turned back into the annotation object that FieldInfo.type promises. Notably, StructInfo_Convert itself obtains annotations by calling the Python-side get_class_annotations and discards the dict right after conversion.

So a C-side fields() would still have to call get_class_annotations, and that's ~80% of the current cost. Numbers on a 20-field struct (3.12, pyperf):

  • fields() on main: ~60-80 us
  • get_class_annotations alone: ~51 us (the irreducible part for any non-caching implementation)
  • assembling the FieldInfos with types magically precomputed (the ideal C floor): ~7 us
  • the cached path in this PR: ~0.4 us

A C port without caching is therefore at best ~1.5x. For C to beat the cache it would have to store the resolved annotations on StructMetaObject - which is the same caching decision, just written in C and harder to maintain. Given that, I think the WeakKeyDictionary cache is the pragmatic endpoint; if we ever port fields() wholesale to C, moving the cache into a C slot can be done then without changing the semantics introduced here.

@Siyet
Siyet temporarily deployed to docs-preview July 3, 2026 06:54 — with GitHub Actions Inactive
@Siyet

Siyet commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main in, no content change.

@ofek the class mutation you flagged in April is gone as of d6919fe. The cache is a module-level WeakKeyDictionary keyed on the struct type, so inspection has no side effects, nothing shows up in dir() or vars(), and the frozen-struct question is moot.

@provinzkraut the C-side question is answered above with measurements: StructMetaObject does not retain resolved annotations, so a C fields() would still call get_class_annotations, about 51 us of the 60 to 80 us total. The ideal C floor is around 7 us against 0.4 us for the cache, so a C port without caching tops out near 1.5x.

One thing I want your call on before this goes in. fields() now hands every caller the same tuple of the same FieldInfo objects, and FieldInfo is not frozen, so fields(Ex)[0].name = "x" poisons the cache process-wide for every later caller. Before this PR each call built fresh objects, so that is an undocumented public behavior change. Freeze FieldInfo, which is itself breaking, or document the result as shared and not to be mutated?

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.

msgspec.structs.fields() is 20x slower than dataclasses.fields()

3 participants