Skip to content

Speed up class sorting and startup, drop the 60-second run limit - #4

Open
bartTC wants to merge 1 commit into
mainfrom
performance
Open

bartTC wants to merge 1 commit into
mainfrom
performance

Conversation

@bartTC

@bartTC bartTC commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Most of tailwhip's time was not spent sorting classes. The sorting hot path read its lookup tables through Dynaconf, whose containers scan every key on each membership test; every class and attribute was re-sorted on every occurrence although real codebases repeat them almost entirely; and a 60-second limit in the thread pool aborted any run that took longer. This PR fixes those three things with byte-identical output, and defers the imports that a single-file stdin run never needs.

Run Files Before After Speedup
Synthetic corpus, already sorted, -q (the pre-commit / CI case) 1,650 2.32 s 0.42 s 5.5×
Synthetic corpus, dry run with 1,636 "Would update" lines 1,650 2.61 s 0.67 s 3.9×
Synthetic corpus at its root, including node_modules/.venv/.git 1,680 2.44 s 0.51 s 4.8×
Real project A (7 MB) 1,764 0.81 s 0.36 s 2.2×
Real project B (6.6 MB) 1,138 0.69 s 0.32 s 2.1×
Real project C (179 MB, 72,302 directories) 9,748 17.17 s 6.58 s 2.6×
One tiny file on stdin 1 88 ms 64 ms 1.4×

Full CLI wall time, best of three, including interpreter startup, Python 3.14.7 on Apple Silicon. Peak RSS rises by about 4 MB on the synthetic corpus (54 → 58 MB) because of the bounded attribute cache; it drops slightly on the real projects.

What changed

  • configuration.py: a plain Lookups dataclass holds the derived tables (*_index dicts, variant_separator, skip_expressions, component_order). _rebuild_lookups() fills it in place whenever the configuration changes, so modules keep a reference to the single instance. The Dynaconf object keeps every user-facing setting and the compiled patterns; only derived data moves out. Rich is created through create_console() on demand.
  • sorting.py: sort_key is @cached per class name and built from a precomputed component table (_COMPONENTS / _component_ranks) instead of the string-dispatching _component_rank() chain. _refresh_tables() rebuilds the table and drops the cache through the existing register_cache_clearer hook. _split_top_level() falls back to str.split() when a class contains no [ or (, which is equivalent and about ten times faster. The separate parse_class cache is gone: a class is parsed exactly once when its key is first built.
  • process.py: _sort_class_string() caches the sorted form of whole attribute values (lru_cache(maxsize=8192)), returning None for values with template expressions so they are left alone.
  • files.py: as_completed(futures) without the timeout; wcmatch, difflib, rich.syntax and rich.padding are imported where they are used.
  • cli.py: stdin mode runs before any console exists; importlib.metadata is imported inside the --version callback.
  • __init__.py: __version__ is resolved lazily through a module __getattr__.
  • CHANGELOG.md: two Performance entries under Changed, one Reliability entry under Fixed.

Verification

  • 196 tests pass; ruff check and ruff format --check are clean.
  • Every one of the 1,650 corpus files produces the same process_text() output before and after, compared by SHA-256.
  • 388 class lists (the shuffled test groups, 300 random samples and edge cases such as supports-[display:grid]:grid, bg-[url(/img/bg.png)], !-mt-4, w-1/2) sort identically under seven configurations: default, reversed component_order, a partial order containing an unknown name, a duplicated entry, custom colors, a two-character variant separator, and no skip expressions.
  • tailwhip.__version__ still resolves and still appears through from tailwhip import *.

Finding 1: the hot path read its lookup tables through Dynaconf

Sorting a class means tokenizing it and asking, per token, whether it is a direction, size, numeric value, color or shade. Each question was a membership test against a dict stored on the Dynaconf settings object, and Dynaconf 3.3 wraps any dict stored on it in its DataDict node type:

# tailwhip/sorting.py, before
for token in tokens:
    if direction is None and token in config.direction_index:
        direction = token
    elif size is None and token in config.size_index:
        size = token
    ...

DataDict.__contains__ does not do a hash lookup. It materializes a tuple of every key, scans it, and on a miss lowercases each key twice looking for a case-insensitive match. Misses are the normal case: a token like 500 is tested against the direction, size and value tables before it reaches the shade table.

# dynaconf/nodes.py and dynaconf/utils/__init__.py, 3.3.5, abridged
class DataDict(dict):
    def __contains__(self, key):
        resolved = ut.find_the_correct_casing(key, tuple(self.keys())) or key
        return super().__contains__(resolved)

def find_the_correct_casing(key, data_keys):
    if not isinstance(key, str) or key in data_keys:
        return key
    for k in data_keys:
        if k.lower() == key.lower():
            return k
        if k.replace(" ", "_").lower() == key.lower():
            return k
    return None
Operation (timeit, 100k iterations) Cost
'x' in plain_dict 10 ns
plain_dict.get(missing) 16 ns
config.prefix_index attribute read 143 ns
'x' in DataDict, key present 2,638 ns
'x' in DataDict, key missing 2,641 ns
DataDict.get(missing) 2,976 ns
config.prefix_index attribute read with dynaconf 3.2.12 (locked by 0.13.0) 48,625 ns

Profile of the steady-state run (sorted corpus, sequential, cProfile, 10.33 s profiled for 2.35 s real):

Function Calls Self Cumulative
dynaconf find_the_correct_casing 70,607 2.995 s 5.368 s (52%)
str.lower 29,760,092 1.606 s 1.606 s
str.replace 7,440,023 0.464 s 0.464 s
sorting._component_rank 1,790,054 0.446 s 2.010 s
sorting.sort_key 255,722 0.444 s 9.847 s
dynaconf LazySettings.__getattr__ 2,399,476 0.337 s 1.154 s
DataDict.__contains__ 193,480 0.300 s 5.661 s
re.Pattern.sub (the regex scan) 3,300 0.073 s 10.255 s
io.open 1,650 0.052 s 0.053 s

The dependency bump is recent (0.13.0 locked dynaconf 3.2.12, 0.14.0 moved to 3.3.5), but it is not a regression story: with 3.2.12 membership tests are fast and every attribute read costs 48.6 µs instead, and the same corpus takes a projected 89 seconds. Reading configuration inside the per-token loop was the design flaw; the Dynaconf version only decided which operation paid for it.

After: the Lookups object
@dataclass(slots=True)
class Lookups:
    """Plain lookup tables derived from the configuration lists."""

    variant_separator: str = ":"
    skip_expressions: tuple[str, ...] = ()
    component_order: tuple[str, ...] = ()
    variant_index: dict[str, int] = field(default_factory=dict)
    prefix_index: dict[str, int] = field(default_factory=dict)
    direction_index: dict[str, int] = field(default_factory=dict)
    size_index: dict[str, int] = field(default_factory=dict)
    value_index: dict[str, int] = field(default_factory=dict)
    color_index: dict[str, int] = field(default_factory=dict)
    shade_index: dict[str, int] = field(default_factory=dict)
    alpha_index: dict[str, int] = field(default_factory=dict)


lookups = Lookups()


def _rebuild_lookups() -> None:
    lookups.variant_separator = config.variant_separator
    lookups.skip_expressions = tuple(config.skip_expressions)
    lookups.component_order = tuple(config.component_order)
    lookups.variant_index = {v: i for i, v in enumerate(config.variants)}
    lookups.prefix_index = {p: i for i, p in enumerate(config.prefixes)}
    ...
    config.APPLY_PATTERNS = [...]
    for clear in _cache_clearers:
        clear()

Finding 2: every occurrence was sorted from scratch

Parsing was cached, but sort_key() was not. Warm, it cost 5.2 µs per class and ran 255,722 times for the sorted corpus, which contains 12,783 distinct classes. Whole attribute values repeat too. The synthetic corpus, built from random combinations, is the worst case at 5.6%; real projects on this machine repeat between 79% and 99.9% of their class attribute strings:

Project Class attributes Distinct values Repeat rate
Project D 6,366,039 5,163 99.9%
Project E 765,887 960 99.9%
Project C 340,305 1,057 99.7%
Project B 36,582 3,127 91.5%
Project F 5,432 695 87.2%
Project A 20,404 4,316 78.8%
Synthetic corpus 49,790 47,021 5.6%
Before and after: sort_key

Before, per occurrence:

def _component_rank(component_type: str, parsed: ParsedClass) -> tuple[int, str]:
    if component_type == "prefix":
        value, index_map, none_rank, unknown_rank = (parsed.prefix, config.prefix_index, -1, -1)
    elif component_type == "direction":
        value, index_map, none_rank, unknown_rank = (parsed.direction, config.direction_index, -1, _MAX_RANK)
    elif component_type == "size":
        ...
    if value is None:
        return (none_rank, "")
    return (index_map.get(value, unknown_rank), value)


def sort_key(classname: str) -> tuple:
    parsed = parse_class(classname)
    key_parts: list = []
    key_parts.append(_variant_sort_key(parsed.variants))
    key_parts.append(_component_rank("prefix", parsed))
    key_parts.append(0 if _is_base_utility(parsed) else 1)
    key_parts.extend(
        _component_rank(component_type, parsed)
        for component_type in config.component_order
        if component_type not in ("variant", "prefix")
    )
    key_parts.append((0 if not parsed.suffix else 1, parsed.suffix))
    key_parts.append(parsed.original)
    return tuple(key_parts)

After, once per distinct class:

_COMPONENTS = {
    "direction": ("direction", "direction_index", -1, _MAX_RANK),
    "size": ("size", "size_index", _MAX_RANK, _MAX_RANK),
    "value": ("value", "value_index", _MAX_RANK, _MAX_RANK),
    "color": ("color", "color_index", _MAX_RANK, _MAX_RANK),
    "shade": ("shade", "shade_index", _MAX_RANK, _MAX_RANK),
    "alpha": ("alpha", "alpha_index", _MAX_RANK, _MAX_RANK),
}
_component_ranks: list[tuple[str, dict[str, int], int, int]] = []


@cache
def sort_key(classname: str) -> tuple:
    parsed = parse_class(classname)
    key_parts: list = [
        _variant_sort_key(parsed.variants),
        (lookups.prefix_index.get(parsed.prefix, -1), parsed.prefix),
        0 if _is_base_utility(parsed) else 1,
    ]
    for attribute, index, none_rank, unknown_rank in _component_ranks:
        value = getattr(parsed, attribute)
        if value is None:
            key_parts.append((none_rank, ""))
        else:
            key_parts.append((index.get(value, unknown_rank), value))
    key_parts.append((0 if not parsed.suffix else 1, parsed.suffix))
    key_parts.append(parsed.original)
    return tuple(key_parts)


def _refresh_tables() -> None:
    ranks = []
    for component in lookups.component_order:
        if component in _COMPONENTS:  # unknown names are ignored, as before
            attribute, table, none_rank, unknown_rank = _COMPONENTS[component]
            ranks.append((attribute, getattr(lookups, table), none_rank, unknown_rank))
    _component_ranks[:] = ranks
    _variant_prefixes[:] = [
        ((variant + "-", variant + "["), rank)
        for variant, rank in lookups.variant_index.items()
    ]
    sort_key.cache_clear()


register_cache_clearer(_refresh_tables)
_refresh_tables()

And the attribute-level cache in process.py:

@lru_cache(maxsize=8192)
def _sort_class_string(classes_str: str) -> str | None:
    for skip_expr in lookups.skip_expressions:
        if skip_expr in classes_str:
            return None
    classes = split_classes(classes_str)
    if not classes:
        return None
    return " ".join(sort_classes(classes))


register_cache_clearer(_sort_class_string.cache_clear)

Finding 3: a 60-second limit on the whole run

as_completed(futures, timeout=60) limits the total time from the first call, not the time per file. Any run longer than 60 seconds ended with a TimeoutError and a traceback, with files left half processed in write mode. Reproduction:

with ThreadPoolExecutor(max_workers=1) as ex:
    fs = [ex.submit(time.sleep, 0.3) for _ in range(3)]
    for f in as_completed(fs, timeout=0.5):
        print("done one")
# done one
# TimeoutError: 2 (of 3) futures unfinished

I hit it on the corpus while measuring with dynaconf 3.2.12 installed: TimeoutError: 461 (of 1650) futures unfinished.

The pool itself does not speed anything up. The work is regex matching and Python-level sorting under the GIL, so wall time equals CPU time with or without threads, and after this change the pool is measurably slower than a plain loop:

Sorted corpus, in-process, quiet Threaded wall Threaded CPU Sequential wall Sequential CPU
Before 2.280 s 2.410 s 2.348 s 2.346 s
After 0.292 s 0.412 s 0.221 s 0.221 s
After, normal verbosity, 1,636 lines printed 0.525 s 0.632 s 0.422 s 0.422 s

This PR only removes the timeout; replacing the pool is a follow-up because it also changes the order in which files are reported.

Finding 4: startup is the whole cost of a single-file run

Piping one small file through tailwhip took 88 ms, 18 ms of which is the interpreter. The rest was imports and module-level work: importlib.metadata.version() ran at import time in __init__.py although only --version needs it, and Rich, wcmatch, difflib and the diff renderer were imported although stdin mode never prints through Rich or scans a directory. Typer does not import Rich itself, so deferring it is a real saving.

Standalone import, over a bare interpreter Cost In this PR
dynaconf 39 ms stays
typer 24 ms stays
rich.console 18 ms deferred to create_console()
importlib.metadata and the version lookup 18 ms deferred to --version and __getattr__
wcmatch.glob 6 ms deferred to find_files()

Result over a bare interpreter: stdin 70 → 50 ms, --version 74 → 54 ms, import tailwhip 68 → 48 ms. What remains is Dynaconf and Typer.

Not changed, measured anyway

  • The regex scan is fine: 55 ms for 8 MB of HTML with 49,790 matches. Dropping IGNORECASE and \b would save a third but changes which attributes match.
  • Console output becomes visible once sorting is cheap: a Rich console.print of a "Would update" line costs about 90 µs (60 µs with highlight=False). For the 1,636 lines of a corpus dry run that is 0.15 to 0.25 s, now roughly 40% of the run.
  • _rebuild_lookups() takes 0.7 ms and runs twice per start; it only looks expensive under cProfile.
  • benchmark.py reports the cached path after its first iteration (median 2.4 ms, maximum 27 ms); it is a regression guard, not a cold-run measure.

Follow-ups, in order of value

  1. Prune ignored directories during discovery. find_files() walks the tree once per default glob and descends into .git, node_modules, virtualenvs and coverage reports; on Project C that is 4.4 s of the remaining 6.6 s, and tailwhip . --write at a project root rewrites vendored CSS. wcmatch's exclude parameter filters after the walk and does not help. A pruned os.scandir walk with a compiled wcmatch matcher takes 418 ms there (71 ms with a plain suffix check) instead of 4,190 ms. This changes which files are touched, so it is a separate decision.
  2. Process files sequentially instead of through the thread pool: simpler, deterministic output order, about 25% faster.
  3. highlight=False on the per-file lines, as the "Already sorted" line already does: a third of the output cost.

The sorting hot path read its lookup tables through Dynaconf, whose
DataDict containers scan every key on each membership test; more than
half of a steady-state run was spent there. The tables now live in a
plain Lookups object that is rebuilt whenever the configuration
changes. Sort keys are cached per class name and built from a
precomputed component table instead of an if/elif chain, sorted
attribute values are cached as well (bounded to 8,192 entries), and
classes without arbitrary values are split with str.split.

Rich, wcmatch, difflib and the package metadata lookup are imported only
when needed, and the console is created only in file mode, which takes
a single-file stdin run from 88ms to 64ms.

as_completed() was called with timeout=60, a limit on the whole run
rather than per file, so any run longer than a minute died with a
TimeoutError.

Measured: a dry run over 1,650 already sorted files drops from 2.3s to
0.4s; three real projects run 2.1 to 2.6 times faster. Output is
byte-identical across that corpus and 388 class lists under 7
configurations.

Co-Authored-By: Claude <noreply@anthropic.com>

This branch had an error being deployed

1 failed (outdated) deployment
github-pages — cd117163 Deployed Oct 2, 2026 by bartTC via deploy #12
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.

1 participant