Variant generation: remaining macro-optimizations
This document collects the macro-level optimization ideas for variant generation that have
not been implemented yet. They complement the ones already in place:
- delta writes: the save phase only writes variants and relationship rows that actually changed;
- incremental generation: entity fingerprints (
VariantGenerationFingerprints) detect what changed
since the last run, and only the affected generator combos are regenerated; - lightweight data loading: the variant relation tables are loaded as slim rows instead of Django
model instances, and fullVariantinstances are only hydrated for the variants being written; - parallel generation: the graph and restore phases fan out across forked worker processes on
platforms that support theforkstart method (production containers do); - element-indexed variant set entries and packed-integer entry encoding (see below);
- element-indexed BFS unblocking in the results ("up") phase (see below).
One idea was implemented and then deliberately removed: a persistent per-combo variant set cache
(ComboVariantSetCache). It only skipped the "down" phase (Graph.variants()), while the results
discovery ("up") phase — which dominates the runtime — still ran in full for every combo, so the
cache did not pay for its own complexity (an extra table, input hashing over structural
fingerprints, and serialization of every variant set).
Already implemented: variant set entry representation and up-phase indexing
Profiling showed the "up" phase (Graph.results → _card_nodes_up) running roughly 15–20x slower
than the "down" phase: it runs once per variant instead of once per combo, and each call installs a
per-variant filter that lazily re-filters every node's cached variant set. Almost all of that time
sat in MinimalSetOfMultisets.subtree() (invoked by VariantSet.filter), doing a linear scan of
every entry with a Python-level multiset issubset on each — tens of millions of comparisons per
run. The following changes address it directly.
Element index over MinimalSetOfMultisets
MinimalSetOfMultisets keeps an element → entries containing it index alongside its set of
entries. Every subset of a probed entry, and every superset, shares at least one element with it
(the empty entry is the sole exception, handled explicitly), so subtree(), add()'s dominance
check, and its superset removal all scan only the union of the relevant buckets instead of the whole
collection. This turns the previously O(n) scans output-sensitive: add() building an n-entry set
drops from O(n²) toward roughly linear, and the per-variant filter()/subtree() cost collapses.
The change is confined to minimal_set_of_multisets.py (plus its .pxd/.pyi) and is transparent
to the rest of the pipeline.
Packed-integer entries (PackedEntry)
Variant set entries are no longer FrozenMultiset (a dict plus a wrapper hashed via a frozenset
of its items). PackedEntry stores an entry as a sorted tuple of element * COUNT_LIMIT + count
integers. Subset tests and merges become linear merge-walks over sorted integers, and hashing and
equality are plain tuple operations — all of which Cython compiles to tight C. Negative elements
(templates, encoded as negated ids by VariantSet.ingredients_to_entry) decode correctly through
Python floor-division/modulo. The encoding is confined behind
VariantSet.ingredients_to_entry/entry_to_ingredients, so the blast radius is small; the visible
behavioral change is that entry_to_ingredients now yields ingredients in ascending-id order.
The save path (_restore_variant) was adjusted to compute order-dependent fields such as the
variant name from the ingredients in their final persisted display order rather than from entry
iteration order, so the stored name stays consistent with what Variant.get_recipe() reads back.
Element-indexed BFS unblocking in the up phase
_card_nodes_up's BFS previously parked combos whose feature requirements were not yet satisfiable
in two flat lists, and re-enqueued every parked combo whenever any new countable/uncountable
feature became available — latently quadratic on deep feature chains. Combos are now indexed by the
specific feature nodes that block them (_uncountable_feature_blockers /
_countable_feature_blockers report which nodes could unblock a stalled combo), and only the combos
actually waiting on a produced feature are woken. The enqueue guards were also reordered to test the
cheap issuperset multiset check before the expensive lazy variant-set filter.
Measured on a synthetic graph (90 cards, 58 combos, 6405 variants), these changes together took the
up phase from ~11.0s to ~5.7s and the down phase from ~0.72s to ~0.41s, with byte-identical recipe
output.
The remaining ideas below are ordered by expected impact.
1. Faster bulk writes on the PostgreSQL side
These matter for runs that still produce large create/update volumes (first generation, full
regenerations after wide-reaching changes). Background for each part:
COPYinstead of multi-rowINSERTfor bulk creates. Django'sbulk_createsends batched
INSERT INTO ... VALUES (...), (...), ...statements. Each batch has to be parsed, planned, and
executed as a regular statement, and every value travels through the SQL text/bind protocol.
PostgreSQL'sCOPY FROMis a dedicated bulk-load path: rows are streamed in a compact format
with almost no per-row protocol or parsing overhead. At hundreds of thousands of rows it is
typically several times faster than batched INSERTs. psycopg 3 exposes it as
cursor.copy('COPY table (cols) FROM STDIN')+copy.write_row(...), so the create half of
perform_bulk_savescould streamCardInVariant/TemplateInVariant/... rows directly.
Caveat:COPYcannot do upserts and reports conflicts as hard errors, so it only replaces the
plain-insert paths.- Merge the create and update passes with an upsert.
bulk_create(..., update_conflicts=True, unique_fields=..., update_fields=...)compiles toINSERT ... ON CONFLICT (...) DO UPDATE,
letting one statement per table handle both new and changed rows instead of separate
bulk_create+bulk_updatepasses.bulk_updateis the slower of the two because it builds
largeCASE WHEN pk=... THEN ...expressions per batch;ON CONFLICT DO UPDATEavoids that
entirely. (This is how the variant set cache upserts its rows already.) SET LOCAL synchronous_commit = offinside the save transaction. By default PostgreSQL
waits for the WAL to be flushed to disk before acknowledging each commit. With
synchronous_commit = off, the commit returns as soon as the WAL record is written to memory;
the flush happens up to ~1s later in the background. The transaction is still atomic and
consistent — the only risk is losing acknowledged work if the server crashes within that
window, which is acceptable here because a crashed generation is simply re-run.SET LOCAL
scopes the setting to the current transaction, so nothing else is affected. This mainly helps
when the save phase issues many statements/batches in sequence.ANALYZEthe variant tables after large writes, so the query planner sees fresh statistics
before the site starts querying the new data (autovacuum gets there eventually, but only after a
delay proportional to the write volume).
With delta writes and incremental generation in place, routine runs write little, so this item
pays off mainly for the worst-case runs.