Skip to content

perf: avoid quadratic scan when inserting keys into a table - #608

Open
Jalst wants to merge 1 commit into
python-poetry:masterfrom
Jalst:perf/fast-key-insertion
Open

Jalst wants to merge 1 commit into
python-poetry:masterfrom
Jalst:perf/fast-key-insertion

Conversation

@Jalst

@Jalst Jalst commented Sep 18, 2026

Copy link
Copy Markdown

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 over self._body from index 0 all the way to the end, doing isinstance checks on every item.

Changes

  1. Table presence flag & cached index:

    • Track _has_tables and _first_table_idx in Container.
    • In _raw_append, _insert_at, and _insert_after, update these when a Table or AoT is added/shifted.
    • In remove, _remove_at, and _replace_at, invalidate _first_table_idx if the affected index was the first table.
    • Preserve both attributes across __copy__ and __setstate__ (for pickle).
  2. Reverse scan instead of full scan:

    • If _has_tables is False (the vast majority of tables and sub-tables without child table headers), the boundary is simply len(self._body).
    • We scan backwards from the boundary to skip trailing nulls/whitespace. For normal key insertions, this finishes in 1–2 iterations (O(1)) instead of scanning the entire container.
    • If _has_tables is True, we use _first_table_idx directly (falling back to a full scan if invalid).

Benchmark

Using the snippet from #540:

import time
import tomlkit

for n in [1000, 2000, 4000, 8000, 16000]:
    doc = tomlkit.parse("[packages]\n")
    packages = doc["packages"]
    t0 = time.perf_counter()
    for i in range(n):
        packages[f"k{i}"] = i
    dt = time.perf_counter() - t0
    print(f"n={n:5d}: {dt*1000:7.1f} ms  ({dt/n*1e6:5.1f} us/key)")

Before:

  • n=1000: 238.1 ms
  • n=2000: 973.9 ms
  • n=4000: 3968.4 ms
  • n=8000: 16247.3 ms

After:

  • 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.

@dimbleby

Copy link
Copy Markdown
Contributor

Explain why not #576

@Jalst

Jalst commented Sep 18, 2026

Copy link
Copy Markdown
Author

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 _first_table_idx and _has_tables, the common case (tables with only scalar keys) becomes O(1) immediately—it just checks the end of the body in 1 step. In practice that makes it about twice as fast (~30 ms vs ~64 ms for 4k keys).

It also avoids touching _insert_at's signature or allocating dict.fromkeys on every insert. If the cached index is ever missing or stale, it falls back to a scan anyway so it's safe.

That said, if you'd rather avoid adding state to Container and prefer #576's bisection, happy to defer to that!

@dimbleby

dimbleby commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

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.

falling back to a full scan if invalid

sounds as though your worst case is worse

@Jalst

Jalst commented Sep 18, 2026

Copy link
Copy Markdown
Author

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 Kylinny left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"] = 1

After 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"] = 1

Here [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.

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

3 participants