fix: stop copying a chunk's orig_elements twice on every serialization - #4472
fix: stop copying a chunk's orig_elements twice on every serialization#4472paulkarayan wants to merge 2 commits into
Conversation
`ElementMetadata.to_dict()` deep-copied every metadata field and then replaced `coordinates`, `data_source`, `orig_elements` and `key_value_pairs` with their serialized form, so those copies were built and discarded. Separately, `_fix_metadata_field_precision()` copied every element in order to round coordinates and `detection_class_prob`, which most elements do not carry. On a chunk, `orig_elements` holds every source element of that chunk, so a single `to_dict()` duplicated the document twice over. Measured on this branch: `to_dict()` on chunks is 4x to 6x faster, `elements_to_json()` on chunks is 6x to 10x faster, and `elements_to_json()` on elements with no `orig_elements` is about 3x faster. `to_dict()` on plain elements is unchanged. Behavior change worth calling out: `Element.id` mints a uuid on first access and caches it on that element. The discarded copies took those new ids with them, so serializing one chunk twice reported different `element_id`s for the same source elements each time. Those ids are now stable across calls. Elements given an explicit or hash-derived id were never affected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWf2tTaaSQCbfxVV75BZEe
Repro-first proofBugfix — ElementMetadata.to_dict and _fix_metadata_field_precision each deep-copied a chunk's whole orig_elements list and discarded the copy Reproduced the broken state
Failing test (red)
Fix
Proof it's resolved
One probe, both states, interleaved in a single process, min of N (bench2.py): Auto-generated from this branch's |
… copy Both strong-review legs caught the same gap independently. The previous commit only stopped the copy for elements with neither `coordinates` nor `detection_class_prob`; every other element still went through `deepcopy(element)`, the copy minted the uuid, and the original stayed unset, so consecutive serializations still disagreed on `element_id`. That is every hi_res-partitioned element, so the claim of stable ids was false for the common case. Mint the id on the caller's element before copying. Adds the coordinates and detection_class_prob variants of the stability test, both of which fail without the mint. Also corrects the CHANGELOG heading to `0.27.6-dev0` so it matches `__version__`. `scripts/version-sync.sh -c` takes the first semver in the changelog and rewrites `__version__.py` with it, so `## 0.27.6` against `0.27.6-dev0` would have failed `make check` in CI. The precedent is commit 4fe4097, whose heading is `## 0.27.5-dev0`; the release commit is what strips the suffix from both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWf2tTaaSQCbfxVV75BZEe
|
Strong review done (2026-09-05, fable + gpt-pro) -- verdict: two blocking findings, both fixed in |
What & why
Problem: Anyone chunking a document was paying to photocopy the whole thing twice on every serialization, then bin both copies.
ElementMetadata.to_dict()deep-copies every metadata field and then replacescoordinates,data_source,orig_elementsandkey_value_pairswith their serialized form, so the copies of those four are built and thrown away. Separately,_fix_metadata_field_precision()copies every element in order to roundcoordinatesanddetection_class_prob, which most elements do not have. On a chunk,orig_elementsholds every source element of that chunk, so a singleto_dict()duplicated the document twice over. In a profiled local pipeline over 45,000 elements,copy.deepcopyand its helpers accounted for roughly 40% of total run time.There is a correctness consequence too.
Element.idmints a uuid on first access and caches it on that element. Because the copies were the objects that got serialized, they took the freshly minted ids with them and the originals stayed unset, so serializing one chunk twice reported differentelement_idvalues for the same source elements each time.Change: Drop the four separately-serialized fields before the copy in
to_dict(); return the element untouched in_fix_metadata_field_precision()when it has neithercoordinatesnordetection_class_prob; and mint the element's id before the copy that remains, so the copy cannot take a fresh one with it.Blast radius: 3/5 -- two functions on the shared serialization path that every caller of
to_dict(),elements_to_json(),elements_to_dicts()andorig_elementsinherits; small, self-contained, and revert-safe.Linked ticket
none
Impact
Library users: chunk-heavy serialization gets materially faster, and repeated serialization of the same element now reports stable
element_idvalues for itsorig_elements. Measured on this branch, one probe run in both states, interleaved in a single process, minimum of N:to_dict, noorig_elementsto_dict, 10orig_elementsto_dict, 40orig_elementselements_to_json, noorig_elementselements_to_json, 10orig_elementselements_to_json, 40orig_elementsThe first row is the one that did not improve and reads slightly worse: it pays four extra dict pops and has no
orig_elementsto skip. The machine was under heavy load during timing, so treat the magnitudes as approximate and that row as indistinguishable from noise.Wire contract / clients: the serialized dict is unchanged in structure and in every value except
element_idfor elements that had none assigned, which was previously regenerated on each call. This reacheselements_to_json()andelements_to_ndjson()as well as the ids insideorig_elements. A caller that recorded those ids and expected a later serialization to produce the same ones was already getting different values every time; it now gets the same ones. Elements given an explicitelement_id, or one assigned byid_to_hash(), were never affected either way.Shared callers that inherit this:
ElementMetadata.to_dict()is reached fromElement.to_dict(), and therefore fromelements_to_dicts()(and itsconvert_to_isd/convert_to_dictaliases),elements_to_json(),elements_to_base64_gzipped_json()andelements_to_ndjson()._fix_metadata_field_precision()is called byelements_to_base64_gzipped_json()(base.py:256),elements_to_json()(base.py:453) andelements_to_ndjson()(base.py:475).Risk / rollback
Low. Two functions, no signature or schema change, revert the commit to back it out. The one deliberate behavior change is the
element_idstability described above.How it was verified
Ran locally on Python 3.13 against this branch. Each of the three new tests was run first against the unfixed sources restored from
HEADwith the tests in place, to confirm it fails for the reason claimed, and then against the fix.Suites run:
test_unstructured/chunking,test_unstructured/documentsandtest_unstructured/stagingall pass, and the widertest_unstructuredtree passes apart from thepartitionandmetricstrees, which needunstructured_inference, andcleaners/test_translate.pyplus the benchmark test, which fail on missingsentencepieceandpytest-benchmarkin my environment and fail the same way without this change.Reviewed by fable and GPT-5.5 Pro before this leaves draft. Both independently found that the first commit left ids unstable for any element carrying
coordinatesordetection_class_prob, which is every hi_res-partitioned element, so its stability claim was false for the common case. My own test could not see it, because I built the fixture from bareTextelements with no coordinates. Fixed, with both variants now covered by a parametrized regression test. fable separately caught that theCHANGELOG.mdheading has to carry the-dev0suffix to match__version__orscripts/version-sync.shfailsmake check: the precedent is commit4fe4097, whose heading is## 0.27.5-dev0, and the release commit is what strips the suffix from both.Not verified:
scripts/version-sync.sh -ccould not run locally, since it needs GNU sed 4.3 and macOS ships BSD sed, so CI is the first real check that the heading and__version__agree. I also have not measured this on a GPU or OCR-heavy end-to-end partition, where model inference dominates and this saving is proportionally much smaller.Proof
Repro. Profiled a 201-document, 45,000-element local pipeline with the real chunker under cProfile.
copy.deepcopywas 10.783 s exclusive over 180,000 calls in a 40.018 s run, and with_deepcopy_list,_deepcopy_dict,_keep_aliveand_deepcopy_atomicthe deepcopy machinery totalled about 16.6 s. Reduced to a standalone reproduction of the id half:Decoding the base64 payload on three consecutive calls gave three different
element_idvalues for the same nested element:2ba29618-...,4e806db8-...,7070f009-....Failing tests, run against the unfixed sources with the new tests in place.
After the fix, same tests, same command:
Second round, after review. With the id-mint line removed from
staging/base.py:With it restored:
2 passed, 66 deselected in 1.33s. All three element kinds checked directly:Suites for the touched areas:
The differential probe, one command run in both states. This is the table under Impact; the row that did not move is the point of showing all six.
Still shaky. The timings were taken under load average 55, so their direction and rough scale are solid and the precise multiples are not. The 0.94x row cannot be separated from noise at that load.
Dependencies / merge order
none