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.

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