Skip to content

Speed up repeated scalar insertion with boundary bisection - #576

Open
davidpavlovschi wants to merge 1 commit into
python-poetry:masterfrom
davidpavlovschi:perf/constant-time-scalar-append
Open

Speed up repeated scalar insertion with boundary bisection#576
davidpavlovschi wants to merge 1 commit into
python-poetry:masterfrom
davidpavlovschi:perf/constant-time-scalar-append

Conversation

@davidpavlovschi

@davidpavlovschi davidpavlovschi commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Closes #540.

Appending a scalar may need to place the new value before child table headers. Two full-prefix operations made repeated insertion quadratic:

  1. _get_last_index_before_table() scanned every existing scalar to find the boundary.
  2. _insert_at() visited every mapped scalar to increment indices, even though only items in the shifted suffix moved.

This change binary-searches the monotonic scalar/table boundary, so it scans neither the scalar prefix nor the table suffix in full. Null entries and non-fixed whitespace inherit the classification of the next meaningful item, preserving the old insertion boundary exactly. Insertion then updates only mapped keys in the suffix that actually shifts.

The remaining cost when the table suffix itself grows comes from Python list insertion and the required index shifts, not from trading a scalar scan for a table scan.

Correctness checks

  • compared the new boundary with the previous forward scan across 744 containers parsed from 287 repository TOML fixtures: 0 mismatches
  • an 8,192-item container containing 4,096 scalars and 4,096 tables required 13 body reads to find the boundary
  • added regression coverage for blank whitespace immediately before a child table
  • existing tests cover regular child tables, arrays of child tables, trailing spacing, index integrity, and round-tripping

Benchmark

Same machine and CPython 3.12, with GC collected and disabled for each insertion loop:

Layout Inserts master this branch
[t] 1,000 226 ms 17 ms
[t] 2,000 789 ms 31 ms
[t] 4,000 3.19 s 64 ms
[t] + [t.child] 1,000 226 ms 15 ms
[t] + [t.child] 2,000 861 ms 32 ms
[t] + [t.child] 4,000 3.45 s 66 ms
[t] + 256 child tables 500 75 ms 51 ms
[t] + 256 child tables 1,000 270 ms 101 ms
[t] + 256 child tables 2,000 937 ms 201 ms

Validation

  • complete test suite: 1,055 passed
  • focused document tests: 75 passed
  • Ruff 0.15.1 check: passed
  • Ruff 0.15.1 format check: passed
  • focused mypy 1.19.1 run: passed
  • diff whitespace check: passed

Agent Drafting Metadata

  • Agent: OpenAI Codex
  • Model: GPT-5 family, Codex
  • Notes: Codex diagnosed the performance paths, drafted the change and regression tests, ran the full validation suite, compared the boundary against the previous implementation across repository fixtures, and measured the before/after benchmarks. David authorized the contribution and owns the submission.

@dimbleby

dimbleby commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

This does not really fix #540, it just adds a special-case fast path.

The quadratic slowdown can still easily be demonstrated with a very small change to the original repro eg

- doc = tomlkit.parse("[t]\n")
+ doc = tomlkit.parse("[t]\n[t.a]\n")

@davidpavlovschi
davidpavlovschi force-pushed the perf/constant-time-scalar-append branch 2 times, most recently from 4d5f9aa to 52f1c19 Compare July 28, 2026 19:38
@davidpavlovschi davidpavlovschi changed the title Speed up scalar insertion without child tables Make scalar insertion linear Jul 28, 2026
@davidpavlovschi

Copy link
Copy Markdown
Author

You are right—the first version only covered the no-child fast path and did not fix the issue generally.

I replaced it with a broader fix for both quadratic operations. Boundary lookup now works backward through the fixed-size table region instead of rescanning the growing scalar prefix, and insertion updates only mapped keys in the suffix that actually shifts.

Your [t]\n[t.a]\n variant now scales 8 ms → 18 ms → 36 ms for 1k/2k/4k keys on my machine, versus 179 ms → 714 ms → 2.49 s on master. I added regression coverage for both [t.a] and [[t.a]], plus round-trip/index checks; the complete suite passes (1,053 tests).

@dimbleby

Copy link
Copy Markdown
Contributor

Feels a bit odd that scanning all tables should be an improvement over scanning all non-tables. Maybe there are lots of tables!

Eg I guess one still could demonstrate quadratic behaviour by alternately adding tables and not-tables, so that the backwards scan was also ever-growing.

But I would agree that this is a less likely usage pattern.

@davidpavlovschi
davidpavlovschi force-pushed the perf/constant-time-scalar-append branch from 52f1c19 to 3f6c126 Compare July 28, 2026 19:53
@davidpavlovschi davidpavlovschi changed the title Make scalar insertion linear Speed up repeated scalar insertion Jul 28, 2026
@davidpavlovschi

Copy link
Copy Markdown
Author

Agreed. I measured the alternating table/scalar case as well: 100/200/400 pairs take 0.35/1.30/6.09 s on this branch, so that worst case remains quadratic. The growing table suffix still has to be shifted by Python's list insertion; eliminating it would need a different body storage strategy, not just a better boundary/index scan.

I have narrowed the PR title, changelog, and description to the behavior it actually improves: repeated scalar insertion while the child-table layout stays stable. That covers the original reproducer and your fixed [t.a] variant without claiming the alternating-layout case is solved.

@dimbleby

dimbleby commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

I think this is not useful, exchanging one slow case (lots of non-table keys) for another (lots of table keys). It is just gaming the repro from the issue report.

I expect that a true fix would require tomlkit to maintain a pointer to the boundary point.

Perhaps a better compromise, avoiding new data structures, would be to bisect to find the insertion point. While on a scalar, too low; while on a table, too high.

Find the scalar/table boundary with a binary search instead of scanning either side in full, and update only mapped keys in the suffix shifted by insertion.

Preserve the previous handling of null entries and non-fixed whitespace by classifying them with the next meaningful body item.
@davidpavlovschi
davidpavlovschi force-pushed the perf/constant-time-scalar-append branch from 3f6c126 to 22b44c9 Compare July 28, 2026 23:27
@davidpavlovschi davidpavlovschi changed the title Speed up repeated scalar insertion Speed up repeated scalar insertion with boundary bisection Jul 28, 2026
@davidpavlovschi

Copy link
Copy Markdown
Author

Thanks — bisection is the better compromise here. I replaced the reverse table scan with a binary search over the scalar/table boundary.

Null entries and non-fixed whitespace take the classification of the next meaningful item, so the result matches the old forward scan. I checked that across 744 containers parsed from 287 repository fixtures and found zero boundary mismatches. An 8,192-item body with 4,096 scalars and 4,096 tables now takes 13 body reads to locate the boundary.

For a fixed suffix of 256 child tables, 500/1,000/2,000 scalar inserts now take 51/101/201 ms, versus 75/270/937 ms on master. Alternating newly-created tables and scalars still pays for list insertion and required index shifts, but the boundary lookup no longer adds another linear scan of either region.

The complete suite passes with 1,055 tests.

@dimbleby

Copy link
Copy Markdown
Contributor

Could more compactly be expressed with a utility from https://docs.python.org/3/library/bisect.html

@davidpavlovschi

Copy link
Copy Markdown
Author

That was my first thought too. The compact form would be bisect_left(range(len(self._body)), True, key=self._is_table_region), but key= was added in Python 3.10 and tomlkit still supports and tests Python 3.9. On 3.9, using bisect here would require a custom lazy sequence adapter around _is_table_region, which is more code and adds an allocation to every insertion. I kept the explicit loop for that compatibility reason. If you would still prefer the adapter form, I can switch it.

@dimbleby

Copy link
Copy Markdown
Contributor

Python 3.9 is eol for many months now, imo it would be reasonable to drop support. But perhaps that does not belong in this pull request.

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.

key insertion is linear in size of document

2 participants