Repository navigation
Conversation
A setter on a table cell paragraph or its run (alignment, style, paragraph_format, font) resolved the checked physical route of the cell on every call: clone the document, serialize the whole body, walk every cell owner before the target and rescan the body for each one, then rewrite the paragraph through a staged reopen. Its cost grew with the cell's place in the table and with the body, so filling a table cell by cell was quadratic. When the cell holds no content control, the mutable cell ordinal and the reader's ordinal name the same paragraph, so the edit now goes through the modeled paragraph in place, as a body paragraph setter does. A cell with a content control keeps the checked route. A run handle takes its paragraph route from the prepared index before the native lookup, and CT_Body::tables is lazy, so selecting an early table stops there. Part of tensorbee#437.
Every staged edit cloned the styles model, serialized it and parsed the styles part again to compare the two, and a staged reopen parsed the part once more. Each edit then cost time in proportion to styles.xml, about 40 ms with a 419 KB styles part where the same edit takes 1.5 ms with rdocx's own 6 KB part. The styles model is now one Arc<CT_Styles>, so a staged clone shares it, and style edits copy it once through Arc::make_mut. The document keeps the styles part bytes it last read with their parse. A flush that finds the same part bytes and the same model Arc compares two pointers and writes nothing. A model that differs is compared by value and serialized when it really changed, as before. A staged reopen whose styles part keeps those bytes takes the kept parse. OpcPackage::get_part_shared returns the shared part bytes for that proof. Part of tensorbee#437.
The rdocx, rdocx-oxml and oxml-opc archives change with the cell edit and styles parse changes and their tests, on main 24af95d. Their measurement date stays as recorded. Tests, python3 scripts/readme_doctests.py Harness, unchanged, measurements only
…les and fragment defaults of tensorbee#444
SectionHandle.replace_xml, Document.remove_endnote and Document.set_picture_size went over RATIO on the macOS runner (2.8 to 3.3 times), and Document.set_content_control_lock on Windows (3.0 times). All four were linear on v0.16.4 too and are cheaper on this pin: tensorbee/rdocx#443 cut their fixed part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cell property setter (vertical_alignment, shading, width, set_border, set_margins) cloned the document, edited the clone and adopted it, assembling the pieces again and rebuilding every owner location: about 4 ms per call in an empty document, growing with the body. Cell.text on a body table cloned the document a second time, reconciled comment removal through two package flushes and reopened the staged package before its publication, and took the cell route from the physical walk: 43 ms per call at 2000 body paragraphs. Paragraph.text on a cell paragraph took its route from the physical walk too, 160 ms per call at 2000 paragraphs. A body table cell property is now set in place, as a body paragraph property is, since each edit validates before it changes w:tcPr. Only writing the cell's first w:tcPr (or removing it) moves the cell's items, as that element comes first: the owners of that cell story then move one item and change route bucket, and debug builds check the moved inventory against a rebuild. CellRef::has_properties tells whether the cell writes w:tcPr. A cell of a table in another story keeps the staged candidate. Cell.text edits the first paragraph in the candidate, as the story table branch already did (Paragraph::set_text keeps every comment, bookmark and permission anchor), and takes its table route from the owner index. Paragraph.text takes its route from paragraph_route, as Run does. At 2000 body paragraphs a property setter costs 0.02 ms, and Cell.text and a cell Paragraph.text cost what a body Paragraph.text costs (about 10 ms). That remaining cost is the revision publication every text setter shares. Part of tensorbee#437.
The rdocx and rdocx-oxml archives change with the cell setter change and its tests. Their measurement date stays as recorded. Tests, python3 scripts/readme_doctests.py Harness, unchanged, measurements only
|
Released in rpptx-v0.15.0 (tag), v0.17.0 (tag). The releases include adapted accessor and edit-cost work, including the whole-cell and Python open/replace/save follow-up. This is one contribution record covering those distinct paths. Refusal, saved-byte and held-handle contracts remain in force. Shared get_part_shared is additive and does not establish a measured PowerPoint speedup. Issues 437 and 445 stay open. Original numerical criteria and unfinished memory/RSS, retention-history and calibration qualification are carried to the S96.1 review and optimization work. No final 79/79, 6 MiB or 68 MiB acceptance claim is made. Thank you @hadim for contributing the reference implementation. |
|
Thank you @hadim for the style-sharing and cell-edit reference implementation and the #445 follow-up diagnosis. Adapted checked publication paths are integrated in F-X282 contract and evidence and F-X283 contract and evidence at eb980e94, available in Word 0.17.0. Functional source-preservation, held-handle, refusal and setter checks passed with successful integrated CI. Closing this PR as superseded, not merged unchanged. #437 and #445 remain open: their original timing ratios and unfinished memory/RSS/history qualification are carried to S96.1 F-X285/F-X286 after F-X284's review. This closure does not claim every numerical target passed. |
Fixes #437
Part of #445 (case 1; case 2 is left to upstream, diagnosis in the issue)
How long an edit takes now depends on what the edit changes, not on the size of
styles.xml, the body, or the cell's place in the table. A setter on a table cell paragraph or one of its runs edits the paragraph in place, as the same setter on a body paragraph does. So filling a table cell by cell takes time linear in its cells.run.text = ...,try_replace_text,add_rowandclone_rowno longer parse and serialize the styles part on every call. The saved bytes do not change.Following #445, the cell setters themselves no longer restage the package:
vertical_alignment,shading,width,set_borderandset_marginson a body table cell edit the cell in place (0.02 ms a call at 2000 body paragraphs, 13 ms before), andCell.textand a cell paragraph'stextcost what a bodyParagraph.textcosts (about 10 ms at 2000 paragraphs, 43 ms and 160 ms before).Cause
ParagraphLocation::edit(crates/rdocx-py/src/paragraph.rs) sent every cell paragraph edit throughDocument::cell_paragraph_story_location. That function clones the document, serializes the whole body withto_xml, then walks the table-cell owners in order until it reaches the target. For each owner on the way,modeled_main_cell_routescans the whole body again.edit_story_paragraphthen rewrote the paragraph throughreplace_story_content, which clones, flushes and reopens the staged package. One call cost about (cell's place) x (body size). F-X260 chose this route becauseCell::paragraph_mutcounts only direct paragraphs while the reader counts controlled ones too, so with a content control in the cell the two ordinals could name different paragraphs. Gettingruns[0]on a cell paragraph paid the same lookup throughParagraphLocation::nativeinPyRun::bind_current.CT_Stylesmodel (clone_for_staging, several times per publication). Everyflush_to_packageserialized it withto_xmland parsed the styles part again to compare the two. A staged reopen (from_package_reusing) parsed the part once more. With a profiler on a release build with symbols, styles parse, serialization, clone and drop took about 88% of arun.textset with a 419 KB styles part.Change
fix(rdocx-py): edit table cell paragraphs in placeParagraphLocation::edit: when the cell holds no controlled paragraph, the reader's and the mutable ordinals agree. The edit then goes throughtable_mut(t).cell(r, c).paragraph_mut(p), as a body paragraph setter goes throughparagraph_mut. The test is that the mutable accessor reaches the reader's last paragraph. A cell with a content control keeps the checked route of F-X260 unchanged.PyRun::bind_currenttakes the paragraph route fromPyDocument::paragraph_route, which reads the prepared index first and falls back to the native lookup, as paragraph handles already do.CT_Body::tables(crates/rdocx-oxml/src/document.rs) is lazy over the body children, same order, soDocument::table(0)stops at the first table instead of collecting every table of the body.docs/hld/10-bindings-spec.md: one paragraph on the cell edit route.perf(rdocx): keep the styles parse across editsDocument::stylesis anArc<CT_Styles>. A staged clone shares it, and style edits go throughArc::make_mut, which copies once when shared. The other changes indocument.rs,epub.rs,field.rs,html.rsandodt.rsare thosemake_mutandArc::newcall sites, mostly in tests.styles_part_parse: the styles part bytes it last read with their parse, as twoArcvalues.styles_differ_from_partanswers what parsing the part and comparing would answer. With the same part bytes (Arc::ptr_eq) it reuses the parse. With the same modelArcit compares two pointers. Otherwise it compares by value. A model equal to the part takes the parsedArc.flush_to_packageserializes the model only when it differs.from_package_reusinggets the carried memo fromreopen_stagedand reuses the parse when the reopened styles part holds the same bytes.OpcPackage::get_part_shared(crates/oxml-opc) returns the sharedArcof a part, found asget_partfinds it, for that pointer proof.docs/hld/03-architecture.md: one paragraph on the shared styles model.record the archive measurements:rdocx,rdocx-oxmlandoxml-opconly.fix(rdocx-py): set table cell properties and text without restaging(rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445, case 1)PyCell::edit(crates/rdocx-py/src/table.rs) sets a body table cell property in place, as a body paragraph property setter does: each of its edits validates before it changesw:tcPr. A cell of a table in another story keeps the staged candidate andadopt_property_edit.w:tcPr, and that element comes first inw:tc, so writing it (or removing it) moves every item of the cell story by one.PyDocument::move_cell_story_itemsthen moves the owners of that story one item and moves each to its new route bucket (WordOwnerIndexes::move_route). Debug builds check the result withassert_word_inventory_rebuilds.CellRef::has_properties(crates/rdocx/src/table.rs) tells whether the cell writesw:tcPr, throughCT_TcPr::is_empty, now public.Cell.textedits the cell's first paragraph in the candidate, as the story-table branch already did, withoutDocument::try_set_cell_text's second clone, comment reconciliation (twoflush_to_package) and staged reopen.Paragraph::set_textkeeps every comment, bookmark and permission anchor, so the reconciliation had nothing to remove. Its table route comes fromindexed_table_locationbefore the native lookup.Paragraph.texttakes its route fromparagraph_route, asRundoes above.docs/hld/10-bindings-spec.md: one paragraph on the cell property route.record the archive measurements:rdocxandrdocx-oxml.Limits
flush_to_packageno longer callsCT_Styles::to_xmlwhen the model equals the part, so a model whose serialization fails while it equals its own part no longer fails the flush. I could not build such a model: a model parsed from bytes serializes.CT_Body::tables), about a nanosecond per child. A body paragraph handle walks the same way.Cell.textandParagraph.textstill grow with the body, linearly: what remains is the revision publication every text setter shares (clone_for_staging,WordPieces::assemble, the owner indexes). rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445 keeps that open with the binding's open + replace cost, which has the same cause.Tests
crates/rdocx-py/tests/test_core.pytest_issue437_cell_paragraph_setters_do_not_restage_the_package: in cells of rows 0 and 19 of a 20 x 3 table, with 0 and 200 body paragraphs after it,alignment,style,paragraph_format.space_afterandruns[0].font.sizedo no staging work, no reopen work and no main-body scan (the debug counters_staging_work,_reopen_work,_main_body_block_scans). The values survive save and reopen, and the neighbour cell is untouched. Without the in-place edit it fails with[0, 0, 0, 0, 4, 4] == [0, 0, 0, 0, 0, 0].test_issue398_controlled_cell_edit_selects_read_ownerstill covers the content-control route.crates/rdocx-py/tests/test_core.pytest_issue437_cell_property_setters_do_not_grow_with_the_body(vertical_alignment,shading,width): per call, at most 1.5 times the empty-body cost plus 0.5 ms with 400 body paragraphs, and at most 3 timesParagraph.alignmentin the same body plus 0.2 ms. On b3be9b6 (this PR before rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445) it fails withCell.vertical_alignment took 2.27 ms per call against 0.00 ms for Paragraph.alignment in the same body. Release builds only: debug builds rebuild the owner inventory after each firstw:tcPr.crates/rdocx-py/tests/test_core.pytest_issue437_cell_text_costs_what_body_paragraph_text_costs: with 400 body paragraphs,Cell.textand a cellParagraph.textcost at most 1.5 times a bodyParagraph.textplus 2 ms. On b3be9b6 it fails withCell.text took 5.75 ms per call against 1.57 ms for a body Paragraph.text. Debug builds check the work counters instead (no reopen, no more main-body scans than a bodyParagraph.text).test_issue388_*andtest_issue398_*cover the live owners across the moved cell routes.crates/rdocx/src/document.rsissue437_edits_keep_the_styles_parse_and_the_styles_part: on a document opened from bytes,try_replace_text,add_empty_table_rowandto_byteskeep the same styles modelArcand the same styles part bytes. A style added afterwards is in the saved part. Without the reuse infrom_package_reusingit fails withadd_empty_table_row parsed the styles again.cargo test -p oxml-opc: 72 passed.-p rdocx-oxml: 761 passed.cargo test -p rdocx(debug,RUST_MIN_STACK=8388608):--lib823 passed, 1 failed, 13 ignored. The failure isfx195_variable_svg_paint_matches_independent_static_instances, which needshb-subset, not installed here.--test regression_test: 2469 passed, 7 ignored.--test integration_test: 466 passed, 5 failed, 8 ignored. Four of the failures are LibreOffice oracles pinned to 26.2.5.2 against this machine's 26.2.6.3 (every_conditional_table_region_matches_word,section_page_semantics_match_pinned_libreoffice_render,odt_reader_matches_pinned_libreoffice_structure,public_authored_theme_and_fonts_match_pinned_word_resolution). The fifth,cross_consumer_render_fallback_before_empty_assignment, runstarget/debug/rdocxand passes oncerdocx-cliis built.cargo clippy -p oxml-opc -p rdocx-oxml -p rdocx -p rdocx-py --all-targets -- -D warnings,cargo fmt --all --checkandscripts/prose_check.py: clean.test_core.py,test_formatting_tables.py,test_python_docx_parity.py,test_shared.pyandtest_rendering_threads.pywith-n 8: 1157 passed, 2 skipped, 62 failed. 57 of the failures are fx195 tests intest_rendering_threads.pythat need theF_X195_*oracle environment. The other five fail the same way on a debug build of 24af95d:test_fx268_picture_normalization_reuses_actual_checked_rounds(4,KeyError: 'producer.seed_eligible'),test_fx200_python_style_saved_output_paints_exact_expected_colours_with_deterministic_native_cli(needs a coordinator-authenticatedRDOCX_CLI), andtest_fx195_word_empty_fonts_preserve_bundled_measurement_fallback.scripts/hash_harness.py --check: 49 entries match after each commit.scripts/readme_doctests.pypasses after re-recording therdocx,rdocx-oxmlandoxml-opcarchives, in the last commit.cargo clippy -p rdocx-oxml -p rdocx -p rdocx-py --all-targets --all-features -- -D warnings,cargo fmt --all --check,scripts/prose_check.pyandscripts/hash_harness.py --check(49 entries match) are clean.Repro
The issue's three scripts on a release build of this branch:
#445 case 1 on a local release build of this branch (no LTO):
On 24af95d the same scripts give x12 to x54 for case 1, x12 to x17 by row and x8.5 by body for case 2, and 3.5 ms then 15.4 ms per call (x4.3) for case 3. See Limits for the last line of case 2.