Skip to content

fix: exclude pydantic 2.13, which is ~9x slower to construct - #178

Open
simontaurus wants to merge 3 commits into
mainfrom
fix/exclude-pydantic-2-13
Open

simontaurus wants to merge 3 commits into
mainfrom
fix/exclude-pydantic-2-13

Conversation

@simontaurus

Copy link
Copy Markdown
Contributor

pydantic>=2.0.0 becomes pydantic>=2.0.0,!=2.13.*, plus a regression guard.

pydantic 2.13 routes ModelPrivateAttr.get_default() through resolve_default_value, which calls inspect.signature on the factory twice per private attribute per construction, uncached. LinkedBaseModel declares two factory-backed private attrs, _links and _extra_iris (src/oold/model/_descriptor.py:1319, :1322), so the cost lands on every model in the stack. Fixed upstream in 2.14.

Measured on one machine, same source, min of interleaved rounds:

pydantic 2 x PrivateAttr(default_factory=dict), bare BaseModel LinkedBaseModel construction
2.12.0 5.62 us -
2.13.4 90.19 us 43.01 us
2.13.5 48.75 us -
2.14.3 3.28 us 4.90 us

Profile on 2.13.4: init_private_attributes is 80.6% of construction cumtime, with 80000 inspect.signature calls for 20000 constructions.

Changes:

  • exclude the series rather than raise the floor, so 2.12 and earlier stay supported; CI has no minimum-version job
  • uv.lock regenerated, resolves to 2.14.3
  • tests/test_construction_cost.py asserts construction stays under 8x plain pydantic. Absolute timings on this machine drift by tens of percent between runs; the ratio holds within 3.38x to 3.53x. Measured 3.4x on 2.14 and 17.8x on 2.13, so the budget sits in the gap. Verified it fails on 2.13.4 (24.3x) and passes on 2.14.3
  • the guard imports the descriptor binding directly, so it runs under either value of OOLD_DESCRIPTOR_BINDING; the legacy binding constructs an order of magnitude slower by design and would need a threshold that measures nothing

801 passed under the descriptor binding, 785 under OOLD_DESCRIPTOR_BINDING=0. The pydantic.v1 shim is still present in 2.14, so the v1 binding is unaffected.

Reworking _links and _extra_iris into lazy dicts was the alternative. It buys 3.6x to 4.9x on real classes against 9x here, and it breaks __eq__ at src/oold/model/_descriptor.py:1532, which compares __pydantic_private__ directly: a fresh instance holds None, one that has been read holds {}, and the __iris__ getter alone is enough to flip it.

- 2.13 calls inspect.signature on a private-attr factory twice per
  attribute per construction, uncached
- LinkedBaseModel declares two, so every model costs ~38 us extra
- measured 43.0 us on 2.13.4 against 4.9 us on 2.14.3
- 2.12 and earlier stay supported; only the 2.13 series is excluded
- absolute timings drift by tens of percent per run, the ratio holds
  within 3.38x to 3.53x
- budget 8x, between the measured 3.4x and the 17.8x of pydantic 2.13
- imports the descriptor binding directly, so it runs under either
  value of OOLD_DESCRIPTOR_BINDING
@github-actions

github-actions Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

Release preview

Merging this PR would release v2.3.1 (current: v2.3.0).

Changelog preview (truncated)
## v2.3.1 (2026-11-15)

### Bug Fixes

- Exclude pydantic 2.13, which is ~9x slower to construct
  ([`48c405c`](https://github.com/OO-LD/oold-python/commit/48c405c00f90b2349de5198b5d355cb1e7cf3fde))

### Documentation

- **controller**: Name controller members for what they do
  ([`648018f`](https://github.com/OO-LD/oold-python/commit/648018fc480934b5b5185c6d0702860392e90c25))

### Testing

- Guard construction cost as a ratio of plain pydantic
  ([`084ca45`](https://github.com/OO-LD/oold-python/commit/084ca45820289c3228b53091106586189cc22fbc))

- Raise the construction budget to 15x
  ([`2a9f89b`](https://github.com/OO-LD/oold-python/commit/2a9f89b234842cead44dca827b3d8136d55bbc09))

Preview via python-semantic-release and conventional commits.

@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

✅ Performance Improvements:
  ✅ test_subclass_inheritance: 0.0638s → 0.0383s (-39.9%, ratio: 0.60x)

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0015s → 0.0013s (-9.7%)
  ➖ test_sqlite_document_store: 0.0016s → 0.0014s (-12.6%)
  ➖ test_local_sparql_store: 0.0286s → 0.0285s (-0.6%)
  ➖ test_oneof_subschema: 0.0441s → 0.0428s (-3.1%)
  ➖ test_enum_docstrings: 0.0394s → 0.0359s (-8.9%)
  ➖ test_class_hierarchy: 0.0401s → 0.0361s (-10.0%)
  ➖ test_core[v1]: 0.0255s → 0.0256s (+0.4%)
  ➖ test_core[v2]: 0.0322s → 0.0316s (-1.7%)
  ➖ test_schema_generation[v1]: 0.0013s → 0.0013s (+0.9%)
  ➖ test_schema_generation[v2]: 0.0028s → 0.0026s (-9.1%)
  ➖ test_simple_json: 0.0005s → 0.0005s (+0.4%)
  ➖ test_complex_graph: 0.0011s → 0.0011s (+1.5%)

============================================================
Summary: 0 regressions, 1 improvements, 12 unchanged
============================================================

✅ No significant performance regressions

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

@codecov

codecov Bot commented Nov 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

- CI measures up to 9.0x healthy, against the 8.0x budget: three of 21
  jobs failed on margin
- the 2.13 regression adds a fixed ~40 us, so it reads 18.7x locally and
  higher on slower runners
@github-actions

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Click to see benchmark comparison
📊 Benchmark Comparison (threshold: 1.3x)
============================================================

➖ Unchanged (within threshold):
  ➖ test_simple_dict_document_store: 0.0014s → 0.0011s (-18.0%)
  ➖ test_sqlite_document_store: 0.0015s → 0.0012s (-16.9%)
  ➖ test_local_sparql_store: 0.0281s → 0.0278s (-1.3%)
  ➖ test_oneof_subschema: 0.0433s → 0.0414s (-4.3%)
  ➖ test_enum_docstrings: 0.0393s → 0.0350s (-11.1%)
  ➖ test_subclass_inheritance: 0.0389s → 0.0407s (+4.7%)
  ➖ test_class_hierarchy: 0.0375s → 0.0380s (+1.3%)
  ➖ test_core[v1]: 0.0297s → 0.0300s (+0.9%)
  ➖ test_core[v2]: 0.0327s → 0.0302s (-7.7%)
  ➖ test_schema_generation[v1]: 0.0011s → 0.0011s (+0.0%)
  ➖ test_schema_generation[v2]: 0.0027s → 0.0024s (-10.7%)
  ➖ test_simple_json: 0.0004s → 0.0004s (+0.3%)
  ➖ test_complex_graph: 0.0011s → 0.0011s (+3.8%)

============================================================
Summary: 0 regressions, 0 improvements, 13 unchanged
============================================================

✅ No significant performance regressions

Threshold: 1.3x (30% slower triggers a regression warning)

Note: Benchmarks are informational only and won't fail the build.

💡 Tip: Download the benchmark-results artifact for detailed JSON data

This branch has not been deployed

No deployments
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.

1 participant