Repository navigation
perf(coco): intern annotation field keys - #126
Merged
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
🟡 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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
parse_py_annotationlooked every field up by passing a C string literal todict::containsand then again tooperator[]. pybind11 builds a fresh Pythonstrfor each of those: allocate, decode UTF-8, hash with siphash24 (a newstrcarries no cached hash), then free. Seven fields, twice each, for every annotation. Interning the keys once removes all of it.evaluate()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 continuousevaluate()loop) putget_cpp_instances— the serial, GIL-held prologue ofEvaluateImages, 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:
pybind11::dict::containssiphash24unicode_decode_utf8Both profiles are saved under
.reports/analysis/phase0-profile/.What changed
Seven field names are interned once via
PyUnicode_InternFromStringand reused for every lookup, so each probe becomes a plainPyDict_GetItemagainst a key with a cached hash. Eachcontains-then-subscript pair collapses into onePyDict_GetItemWithErrorcall, 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_crowdstill takes precedence overiscrowd, 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 checkandruff formatclean. NewTestAnnotationFieldParsingcovers 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_crowdandiscrowdshould be interchangeable. They are not, and they were not before this change either: the parser readsis_crowdwith aniscrowdfallback, but_preparederives itsignoreflag fromiscrowdalone (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 presentiscrowddecides the outcome despite the parser preferringis_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 theis_crowdspelling should be honoured by_preparetoo.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.jsonwith 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.mdrecords that the pinned cibuildwheel buildscp314twheels by default while neither extension declaresPy_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.