Skip to content

fix: stop copying a chunk's orig_elements twice on every serialization - #4472

Draft
paulkarayan wants to merge 2 commits into
mainfrom
pk/element-metadata-to-dict-deepcopy
Draft

fix: stop copying a chunk's orig_elements twice on every serialization#4472
paulkarayan wants to merge 2 commits into
mainfrom
pk/element-metadata-to-dict-deepcopy

Conversation

@paulkarayan

@paulkarayan paulkarayan commented Sep 5, 2026

Copy link
Copy Markdown

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 replaces coordinates, data_source, orig_elements and key_value_pairs with 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 round coordinates and detection_class_prob, which most elements do not have. On a chunk, orig_elements holds every source element of that chunk, so a single to_dict() duplicated the document twice over. In a profiled local pipeline over 45,000 elements, copy.deepcopy and its helpers accounted for roughly 40% of total run time.

There is a correctness consequence too. Element.id mints 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 different element_id values 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 neither coordinates nor detection_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() and orig_elements inherits; 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_id values for its orig_elements. Measured on this branch, one probe run in both states, interleaved in a single process, minimum of N:

case before after speedup
to_dict, no orig_elements 17.2 ms 18.3 ms 0.94x
to_dict, 10 orig_elements 323.1 ms 74.2 ms 4.36x
to_dict, 40 orig_elements 399.1 ms 62.4 ms 6.40x
elements_to_json, no orig_elements 77.9 ms 22.2 ms 3.51x
elements_to_json, 10 orig_elements 440.6 ms 75.3 ms 5.85x
elements_to_json, 40 orig_elements 666.1 ms 63.0 ms 10.57x

The first row is the one that did not improve and reads slightly worse: it pays four extra dict pops and has no orig_elements to 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_id for elements that had none assigned, which was previously regenerated on each call. This reaches elements_to_json() and elements_to_ndjson() as well as the ids inside orig_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 explicit element_id, or one assigned by id_to_hash(), were never affected either way.

Shared callers that inherit this: ElementMetadata.to_dict() is reached from Element.to_dict(), and therefore from elements_to_dicts() (and its convert_to_isd / convert_to_dict aliases), elements_to_json(), elements_to_base64_gzipped_json() and elements_to_ndjson(). _fix_metadata_field_precision() is called by elements_to_base64_gzipped_json() (base.py:256), elements_to_json() (base.py:453) and elements_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_id stability 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 HEAD with the tests in place, to confirm it fails for the reason claimed, and then against the fix.

Suites run: test_unstructured/chunking, test_unstructured/documents and test_unstructured/staging all pass, and the wider test_unstructured tree passes apart from the partition and metrics trees, which need unstructured_inference, and cleaners/test_translate.py plus the benchmark test, which fail on missing sentencepiece and pytest-benchmark in 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 coordinates or detection_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 bare Text elements with no coordinates. Fixed, with both variants now covered by a parametrized regression test. fable separately caught that the CHANGELOG.md heading has to carry the -dev0 suffix to match __version__ or scripts/version-sync.sh fails make check: the precedent is commit 4fe4097, whose heading is ## 0.27.5-dev0, and the release commit is what strips the suffix from both.

Not verified: scripts/version-sync.sh -c could 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.deepcopy was 10.783 s exclusive over 180,000 calls in a 40.018 s run, and with _deepcopy_list, _deepcopy_dict, _keep_alive and _deepcopy_atomic the deepcopy machinery totalled about 16.6 s. Reduced to a standalone reproduction of the id half:

$ python -c "from unstructured.documents.elements import Text, ElementMetadata; \
    md = ElementMetadata(orig_elements=[Text('hello world')]); e = Text('chunk', metadata=md); \
    print(e.to_dict() == e.to_dict())"
False

Decoding the base64 payload on three consecutive calls gave three different element_id values for the same nested element: 2ba29618-..., 4e806db8-..., 7070f009-....

Failing tests, run against the unfixed sources with the new tests in place.

$ python -m pytest test_unstructured/documents/test_elements.py -k "does_not_deep_copy or same_way_on_every_call" test_unstructured/staging/test_base.py -k "does_not_deep_copy or same_way_on_every_call or leaves_elements_with_nothing_to_round" -q
FAILED test_unstructured/documents/test_elements.py::DescribeElementMetadata::and_it_does_not_deep_copy_the_sub_objects_it_reserializes
FAILED test_unstructured/documents/test_elements.py::DescribeElementMetadata::and_it_serializes_orig_elements_the_same_way_on_every_call
FAILED test_unstructured/staging/test_base.py::test_fix_metadata_field_precision_leaves_elements_with_nothing_to_round_alone
3 failed, 145 deselected in 1.71s

After the fix, same tests, same command:

2 passed, 64 deselected in 2.41s     (documents)
1 passed, 81 deselected in 1.31s     (staging)

Second round, after review. With the id-mint line removed from staging/base.py:

$ python -m pytest test_unstructured/documents/test_elements.py -k "precision_still_has_to_be_rounded" -q
FAILED ...and_that_holds_for_elements_whose_precision_still_has_to_be_rounded[coordinates]
FAILED ...and_that_holds_for_elements_whose_precision_still_has_to_be_rounded[detection_class_prob]
2 failed, 66 deselected in 2.24s

With it restored: 2 passed, 66 deselected in 1.33s. All three element kinds checked directly:

with coordinates, two to_dict calls equal: True
with detection_class_prob, two to_dict calls equal: True
no coordinates, two to_dict calls equal: True

Suites for the touched areas:

$ python -m pytest test_unstructured/chunking test_unstructured/documents test_unstructured/staging -q
673 passed, 25 skipped in 13.18s

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.

case                       baseline        fixed   speedup
to_dict, no origs           17.2 ms      18.3 ms     0.94x
to_dict, 10 origs          323.1 ms      74.2 ms     4.36x
to_dict, 40 origs          399.1 ms      62.4 ms     6.40x
to_json, no origs           77.9 ms      22.2 ms     3.51x
to_json, 10 origs          440.6 ms      75.3 ms     5.85x
to_json, 40 origs          666.1 ms      63.0 ms    10.57x

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

Review in cubic

`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
@paulkarayan

Copy link
Copy Markdown
Author

Repro-first proof

Bugfix — 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

  • Environment: local
  • How: Profiled a 201-document / 45,000-element local ingest pipeline (real Chunker.run on
    unstructured 0.27.5) under cProfile, then read the source at the top of the profile.
    Reproduced standalone with:
    python -c "from unstructured.documents.elements import Text, ElementMetadata; md = ElementMetadata(orig_elements=[Text('hello world')]); e = Text('chunk', metadata=md); print(e.to_dict() == e.to_dict())"
  • Observed: copy.deepcopy was 10.783 s exclusive over 180,000 calls in a 40.018 s run, and the
    deepcopy machinery (_deepcopy_list, _deepcopy_dict, _keep_alive, _deepcopy_atomic)
    totalled about 16.6 s, roughly 40% of the run. Reading the source: to_dict() deep-copies
    every field and then overwrites coordinates, data_source, orig_elements and
    key_value_pairs with their serialized form, and _fix_metadata_field_precision() copies
    every element again to round two fields most elements do not carry.
    Side effect: the standalone repro above prints False. Element.id mints a uuid on first
    access and caches it, so each call materialized ids on the throwaway copies and reported
    different element_ids for the same source elements.
  • Evidence: /Users/pk/orca/workspaces/wonderland-experimental/rust-and-clojure-ports/experiments/ingest-processes/RESULTS.md

Failing test (red)

  • test_unstructured/documents/test_elements.py::DescribeElementMetadata::and_it_serializes_orig_elements_the_same_way_on_every_call (unit) — committed
  • transcribed by the author (not captured by a runner)
$ .venv-poc/bin/python -m pytest test_unstructured/documents/test_elements.py -k "does_not_deep_copy or same_way_on_every_call" test_unstructured/staging/test_base.py -k "does_not_deep_copy or same_way_on_every_call or leaves_elements_with_nothing_to_round" -q
FAILED test_unstructured/documents/test_elements.py::DescribeElementMetadata::and_it_does_not_deep_copy_the_sub_objects_it_reserializes
FAILED test_unstructured/documents/test_elements.py::DescribeElementMetadata::and_it_serializes_orig_elements_the_same_way_on_every_call
FAILED test_unstructured/staging/test_base.py::test_fix_metadata_field_precision_leaves_elements_with_nothing_to_round_alone
3 failed, 145 deselected in 1.71s
(run against the unfixed sources, restored from HEAD, with the new tests in place)

Fix

  • Drop the separately-serialized fields before the copy in to_dict(); skip the element copy in _fix_metadata_field_precision() when there is nothing to round
  • Files: unstructured/documents/elements.py, unstructured/staging/base.py

Proof it's resolved

  • Test green: yes
  • Environment: local
  • Evidence: `Same three tests, same command, against the fixed sources:
    2 passed, 64 deselected (elements) + 1 passed, 81 deselected (staging)
    Full suites for the touched areas:
    test_unstructured/chunking + documents + staging -> 671 passed, 25 skipped
    test_unstructured (minus partition/metrics, heavy deps) -> 1492 passed, 25 skipped,
    5 failed, 1 error. All 6 are environment gaps present before this change:
    cleaners/test_translate.py needs sentencepiece, the benchmark test needs
    pytest-benchmark.

One probe, both states, interleaved in a single process, min of N (bench2.py):
case baseline fixed speedup
to_dict, no origs 17.2 ms 18.3 ms 0.94x
to_dict, 10 origs 323.1 ms 74.2 ms 4.36x
to_dict, 40 origs 399.1 ms 62.4 ms 6.40x
to_json, no origs 77.9 ms 22.2 ms 3.51x
to_json, 10 origs 440.6 ms 75.3 ms 5.85x
to_json, 40 origs 666.1 ms 63.0 ms 10.57x
The no-origs to_dict row is the one that did not improve and reads slightly worse; it
pays four extra dict pops and gains nothing, since there is no orig_elements to skip.`


Auto-generated from this branch's .proof.toml (repro-first proof gate). Advisory.

… 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
@paulkarayan

Copy link
Copy Markdown
Author

Strong review done (2026-09-05, fable + gpt-pro) -- verdict: two blocking findings, both fixed in dbc99bd before this left draft. Advisory report kept locally; re-run only on purpose (new commits that change the risk surface).

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