Conversation
|
Explain why not #576 |
|
Mainly because you were right in the discussion on #576: maintaining the boundary pointer is the actual clean fix. Bisection in #576 is a neat compromise to stay stateless, but it still does O(log N) iterations per insertion and each step has to loop past nulls/whitespace. By tracking It also avoids touching That said, if you'd rather avoid adding state to |
|
I don't care, you'll have to get a maintainer to agree one way or another I do think that if you point your bot at an issue that already has an open PR - then you should have an obligation to explain yourself.
sounds as though your worst case is worse |
|
Fair point, that's completely on me. I saw your comment on #576 about maintaining the pointer, wanted to see if it was actually doable without adding too much complexity, and got ahead of myself without linking #576 in the description. Definitely wasn't trying to step on David's toes or push redundant code. Happy to leave it to @davidpavlovschi and @frostming to decide which direction they prefer, or close this if they'd rather stick with #576. |
Kylinny
left a comment
There was a problem hiding this comment.
I read through the full diff and ran the PR head against the base commit to check both the performance claim and the cache invalidation behavior.
The performance improvement is real. On the scenario from #540, I measured 0.69s → 5.84s on base when increasing from 1,000 to 3,000 sequential insertions, versus 0.03s → 0.16s with this PR. That is consistent with the intended quadratic-to-linear improvement. The full test suite also passes locally (380 tests, including the two added here).
I'm requesting changes because I found two mutation sequences that break the cache invariant and silently change where subsequently inserted keys are rendered.
_first_table_idx must point to the first header-rendering item, while _has_tables is used as a cached indication that such an item exists. However, whether a dotted-key table renders a header is not a stable property of its parent body slot: it depends on the table's children. The current revalidation only checks the slot at _first_table_idx, so it cannot detect an earlier dotted-key table becoming header-rendering after a child mutation.
1. A child mutation makes an earlier dotted-key table render a header
doc = parse("a.b = 1\n[t]\nx = 1\n")
doc.item("a").value["sub"] = table()
doc["z"] = 1After the child mutation, the a subtree renders [a.sub]. With this PR, z = 1 is inserted after that subtree and therefore becomes part of [a.sub]; reparsing yields a.sub.z == 1, with no top-level z.
Base inserts z at the top level.
The cached slot still points to [t], and that slot remains a valid header, so revalidation succeeds without examining the earlier a item. The parent container is not notified when the child table changes, so its cached boundary cannot reflect this transition.
2. _raw_append() claims a non-first table as _first_table_idx
doc = parse("[t1]\nx = 1\n[t2]\ny = 2\n")
del doc["t1"] # invalidates _first_table_idx to None
doc["t3"] = table() # _raw_append records t3 as the first table
doc["z"] = 1Here [t2] is still earlier than [t3], but _raw_append() sets _first_table_idx to the newly appended [t3] because the cache is None. The subsequent insertion scans backward from [t3], stops after [t2], and renders z = 1 inside [t2].
The None state is valid here—it is produced by remove() to mean that the boundary must be rediscovered. A straightforward fix for this case would be for _raw_append() not to claim _first_table_idx when it is unknown. Leaving it as None lets the next _get_last_index_before_table() perform the intended one-time rescan.
I also ran a small differential fuzzer: 40 seeds × 60 random insert/remove/replace/child-mutation operations, comparing as_string() on the PR and base. Two seeds diverged, both with this same misplaced-key shape. That supports these being reachable through normal mutation APIs rather than only through the minimal reproductions above.
One minor observation, not a blocker by itself: inserting a non-header Table/AoT at or before _first_table_idx in _insert_at() or _insert_after() can temporarily leave the cached index stale. The next lookup notices that the cached slot is no longer a header and rescans, so this appears to cost one extra scan rather than corrupt output.
For the child-mutation case, I don't see a similarly local invalidation fix because the parent container does not observe mutations below its immediate body. Possible approaches seem to be:
- propagate invalidation upward when child tables mutate; or
- avoid trusting the cached boundary while an earlier dotted-key table can still change whether it renders a header.
Plain non-dotted Table/AoT headers are structurally stable, so there may still be a sound fast path for those.
The performance work is valuable and the measured improvement is substantial, but the invalidation model needs to cover these mutation paths before the cache is safe to merge.
Fixes #540
Problem
As reported by @dimbleby in #540, inserting keys into a table becomes progressively slower (O(N^2) overall). When populating a table with 8,000 keys, it took ~16.2 seconds locally.
Profiling the benchmark showed that almost all the time (~95%) is spent in
_get_last_index_before_table(). On every key insertion, it iterated overself._bodyfrom index 0 all the way to the end, doingisinstancechecks on every item.Changes
Table presence flag & cached index:
_has_tablesand_first_table_idxinContainer._raw_append,_insert_at, and_insert_after, update these when aTableorAoTis added/shifted.remove,_remove_at, and_replace_at, invalidate_first_table_idxif the affected index was the first table.__copy__and__setstate__(for pickle).Reverse scan instead of full scan:
_has_tablesis False (the vast majority of tables and sub-tables without child table headers), the boundary is simplylen(self._body)._has_tablesis True, we use_first_table_idxdirectly (falling back to a full scan if invalid).Benchmark
Using the snippet from #540:
Before:
n=1000: 238.1 msn=2000: 973.9 msn=4000: 3968.4 msn=8000: 16247.3 msAfter:
n=1000: 7.3 ms (32x faster)n=2000: 14.8 ms (65x faster)n=4000: 30.6 ms (130x faster)n=8000: 63.2 ms (257x faster)n=16000: 126.8 ms (linear O(N), ~7.9 us/key)All existing tests pass and a regression test for insertion scaling/order has been added.