Speed up repeated scalar insertion with boundary bisection - #576
Speed up repeated scalar insertion with boundary bisection#576davidpavlovschi wants to merge 1 commit into
Conversation
|
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") |
4d5f9aa to
52f1c19
Compare
|
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 |
|
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. |
52f1c19 to
3f6c126
Compare
|
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 |
|
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.
3f6c126 to
22b44c9
Compare
|
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 The complete suite passes with 1,055 tests. |
|
Could more compactly be expressed with a utility from https://docs.python.org/3/library/bisect.html |
|
That was my first thought too. The compact form would be |
|
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. |
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:
_get_last_index_before_table()scanned every existing scalar to find the boundary._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
Benchmark
Same machine and CPython 3.12, with GC collected and disabled for each insertion loop:
[t][t][t][t]+[t.child][t]+[t.child][t]+[t.child][t]+ 256 child tables[t]+ 256 child tables[t]+ 256 child tablesValidation
Agent Drafting Metadata