Skip to content

perf(coco): intern annotation field keys - #126

Merged
MiXaiLL76 merged 4 commits into
mainfrom
perf/parse_py_annotation
Aug 29, 2026
Merged

MiXaiLL76 merged 4 commits into
mainfrom
perf/parse_py_annotation

Conversation

@Borda

@Borda Borda commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

parse_py_annotation looked every field up by passing a C string literal to dict::contains and then again to operator[]. pybind11 builds a fresh Python str for each of those: allocate, decode UTF-8, hash with siphash24 (a new str carries no cached hash), then free. Seven fields, twice each, for every annotation. Interning the keys once removes all of it.

Metric Before After Change
evaluate() 0.81026 s 0.66021 s 1.2273x, -18.52%
Peak RSS 1178.05 MiB 1188.19 MiB +0.86% (gate: 2%)

Evaluator output digest — SHA-256 over the full precision, recall and scores tensors plus summary stats — is unchanged.

Why this function

A sampled profile (sample, 25 s over a continuous evaluate() loop) put get_cpp_instances — the serial, GIL-held prologue of EvaluateImages, which calls this parser — at roughly 78% of the native evaluate call. The matching loops that look expensive on paper run across ~14 workers and are nearly idle by comparison: ~75 samples each against 20,436 on the main thread. The bottleneck was never the matcher; it was converting annotation dicts, serially, with the GIL held.

Re-profiling after the change confirms the mechanism rather than just the timing:

Symbol Before After
pybind11::dict::contains 2942 0
siphash24 890 1
unicode_decode_utf8 1369 1

Both profiles are saved under .reports/analysis/phase0-profile/.

What changed

Seven field names are interned once via PyUnicode_InternFromString and reused for every lookup, so each probe becomes a plain PyDict_GetItem against a key with a cached hash. Each contains-then-subscript pair collapses into one PyDict_GetItemWithError call, halving the lookups.

Behaviour is deliberately preserved, not tidied: each field stays best-effort, so an unconvertible value leaves that field at its default rather than propagating; is_crowd still takes precedence over iscrowd, including when both keys are present. The keys are leaked on purpose — static destructors may run after interpreter teardown without the GIL, and releasing Python objects there is undefined behaviour. Seven small strings is the right price for removing that hazard.

Testing

Full suite 211 passed, 2 skipped; ruff check and ruff format clean. New TestAnnotationFieldParsing covers all five crowd-key spellings, three unconvertible field values, and the all-optional-fields-absent case. Those tests also pass against the unmodified extension, which is what makes them meaningful: they characterise existing behaviour rather than encoding the change. A separate differential fixture — annotations with absent, malformed, and alternate-spelling fields — produced byte-identical digests on both builds.

Latent issue found, not fixed here

While writing the tests I asserted that is_crowd and iscrowd should be interchangeable. They are not, and they were not before this change either: the parser reads is_crowd with an iscrowd fallback, but _prepare derives its ignore flag from iscrowd alone (gt["ignore"] = int(gt.get("iscrowd", 0))). So {"is_crowd": 1} evaluates to AP 1.0 while {"iscrowd": 1} gives -1.0, and when both are present iscrowd decides the outcome despite the parser preferring is_crowd. Identical on baseline and candidate, so it is out of scope here — the new tests pin the current matrix so it cannot drift silently, and the commit body records it. Worth deciding separately whether the is_crowd spelling should be honoured by _prepare too.

Benchmark method

Interleaved A/B over frozen builds: each configuration built, its package directory snapshotted, the two run alternately via PYTHONPATH — 7 outer rounds each, 5 inner per measurement, medians. Sequential before/after runs were avoided after an earlier measurement in this series was confounded by a stale in-tree .so, which inflated an unrelated result by 0.41 s until the baseline was rebuilt from clean sources.

Limits

One host (macOS arm64, Python 3.10.11), one synthetic COCO2017-shaped bbox fixture — not real instances_val2017.json with real predictions. Segmentation and keypoint evaluation share this parser but were not benchmarked; they should gain similarly, since the cost removed is per-annotation and task-independent. RSS spreads overlap between configurations, so the +0.86% is at noise level rather than a measured regression.

Related

Profiling context and the discarded matcher-optimization proposals are in .plans/active/todo_native-matcher-optimization.md. Separately, .reports/analysis/bbox-bottleneck-and-freethreading-2026-08-24.md records that the pinned cibuildwheel builds cp314t wheels by default while neither extension declares Py_MOD_GIL_NOT_USED, so those wheels force the GIL back on process-wide — worth addressing before the next release, independently of this PR. Note that the interned statics here are initialised under the GIL on first call and never mutated, so they are safe as-is and do not add to that work.

Changes:
- Intern the seven annotation field names once and reuse them for every lookup in parse_py_annotation, replacing the C string literals passed to dict::contains and operator[].
- Replace each contains-then-subscript pair with a single PyDict_GetItemWithError probe, halving the lookups per field.
- Keep the per-field best-effort behaviour: an unconvertible value still leaves that field at its default instead of propagating.
- Preserve the is_crowd over iscrowd precedence, including the case where both keys are present.
- Leak the interned keys deliberately rather than freeing them in a static destructor, which may run after interpreter teardown without the GIL.
- Add TestAnnotationFieldParsing covering every crowd-key spelling, three unconvertible field values, and the all-optional-fields-absent case.

Rationale:
- Passing a C string literal makes pybind11 build a fresh Python str per lookup: allocate, decode UTF-8, hash with siphash24 because a new str carries no cached hash, then free. Seven fields per annotation made that the dominant cost of annotation parsing, which a sampled profile placed at roughly 78% of the native evaluate call.

Impact:
- Measured on a 2,000 image x 300 detection synthetic bbox workload, maxDets=[1,10,500], interleaved A/B over frozen builds, 7 rounds each, medians: evaluate 0.81026s -> 0.66021s (1.2273x, -18.52%).
- Peak RSS 1178.05 MiB -> 1188.19 MiB (+0.86%), within the 2% allowance.
- Re-profiling after the change: pybind11::dict::contains samples 2942 -> 0, siphash24 890 -> 1, unicode_decode_utf8 1369 -> 1.
- Evaluator output digest over precision, recall, scores and summary stats is unchanged, including a fixture built to exercise absent, malformed, and alternate-spelling fields.

Verification:
- Full suite: 211 passed, 2 skipped.
- The new tests also pass against the unmodified extension, confirming they characterise existing behaviour rather than the change.
- ruff check and ruff format clean.

Residual limits:
- parse_py_annotation reads is_crowd while _prepare derives its ignore flag from iscrowd alone, so the two spellings are not interchangeable end to end. That asymmetry predates this commit and is pinned by the new tests rather than altered.
- Measurements cover one macOS arm64 host and a synthetic bbox fixture; segmentation and keypoint paths share this parser but were not benchmarked.

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda
Borda requested review from MiXaiLL76 and a balanced review from Copilot August 24, 2026 14:48
@Borda Borda added the enhancement New feature or request label Aug 24, 2026

Copilot AI left a comment

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.

🟡 Changes recommended

Interning failures can crash, and some tests do not exercise the intended native paths.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Optimizes native annotation parsing by reusing interned Python keys and adds regression coverage.

Changes:

  • Replaces repeated dictionary lookups with interned-key C API probes.
  • Adds annotation parsing behavior tests.
File summaries
File Description
csrc/faster_eval_api/coco_eval/dataset.cpp Optimizes annotation field lookup.
tests/test_cpp_safety_regressions.py Adds parser regression tests.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread csrc/faster_eval_api/coco_eval/dataset.cpp
Comment thread tests/test_cpp_safety_regressions.py Outdated
Comment thread tests/test_cpp_safety_regressions.py Outdated
@Borda
Borda marked this pull request as draft August 24, 2026 16:02
Changes:
- Validate and clean up partially interned native annotation keys before any parser lookup can use them.
- Preserve best-effort defaults for malformed optional fields while distinguishing lookup errors from absent crowd keys.
- Exercise native parsing directly for crowd-key precedence, legacy fallback, conversion failure, lookup failure, and absent fields.

Impact:
- Python mappings with failing or colliding key lookups cannot silently take the legacy crowd fallback path.
- Existing tolerant annotation parsing remains compatible while native parser behavior is regression-tested.

Verification:
- `env PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -q tests/test_cpp_safety_regressions.py` (13 passed).
- `pre-commit run --files csrc/faster_eval_api/coco_eval/dataset.cpp tests/test_cpp_safety_regressions.py`, `rtk ruff check tests/test_cpp_safety_regressions.py`, and `rtk git diff --check` passed.

Residual limits:
- PyUnicode interning allocation failure is handled but was not fault-injected.

---

Co-authored-by: Codex <codex@openai.com>
@Borda
Borda marked this pull request as ready for review August 24, 2026 16:15
…or, but does not prohibit

copying/moving. Currently, it's safe (only `new` + `const&`), but any
future copy-by-value will result in a double `Py_XDECREF` and corrupt the interpreter's
refcounts. Add `= delete` to the copy/move constructors.
@MiXaiLL76
MiXaiLL76 merged commit 9c26e9a into main Aug 29, 2026
15 checks passed
@MiXaiLL76
MiXaiLL76 deleted the perf/parse_py_annotation branch August 29, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants