Skip to content

Improve performance of query materializer - #470

Merged
kofrezo merged 5 commits into
mainfrom
dk_query_materializer_perf
Sep 23, 2026
Merged

kofrezo merged 5 commits into
mainfrom
dk_query_materializer_perf

Conversation

@kofrezo

@kofrezo kofrezo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

This PR contains changes reducing the runtime of queries returning thousands of objects.

  • Defer intern_ip if not requested - intern_ip is a "special" attribute that was always present in the past
  • Fetch supernets once instead of per row
  • Map server attributes by server_id (plain int) instead of Server objects (hash function)
  • Hash Attribute by attribute_id directly
  • Read stored attribute rows as tuples

prefetch_related() issues its own query built from Server._base_manager,
so defer("server__intern_ip") on the outer queryset never reached it -
that spelling only takes effect for select_related() traversals. Every
prefetched Server therefore parsed intern_ip through netfields and
ipaddress, which is pure Python and accounted for ~10% query time of
profiled queries, even though intern_ip is not a real attribute and never
appears in a restrict clause.

Pass an explicit Prefetch queryset at the two sites where the prefetched
Server is only used as a dict key. The reverse branch keeps the full row
on purpose: sa.server is stored as an attribute value there, so it can
escape into the results and reach _get_servers_to_join(), which reads
intern_ip - deferring it would cost a query per object.

This can be reverted once we removed intern_ip fully.
_add_supernet_attribute() read its join through Server.objects.raw(),
which builds one model instance per result row. The join yields a row
per (host, net) pair, so a large query created tens of thousands of
Server objects - each parsing net.intern_ip through netfields - to
represent far fewer distinct networks.

Select only the ids, read them through a plain cursor, and fetch the
distinct nets once. This trades two extra round trips for fewer
model instantiations and the same number of inet parses which pays out
especially on queries with many results.

The nets are still fetched whole: they are stored as attribute values
and can reach a nested QueryMaterializer via _get_servers_to_join(),
which reads intern_ip.

host_attr_name used to ride along on each per-row Server object and was
read back off the previously stored supernet for the conflict warning.
One net object is now shared by every host inside it, so it is tracked
separately, keyed by host id. The two inet_address_family columns the
query selected were never read by anything and are gone.
_server_attributes was keyed by Server instances. Model.__hash__() is a
Python-level call that goes through _is_pk_set() and the pk property,
and the map is looked up once per server per attribute - 1174077 times
on a large query. Plain ints hash in C.

The key change pays for itself twice over: with an int key the generic
branch of _add_attributes() can take sa.server_id straight off the
attribute row, so its prefetch_related() of Server disappears - and with
it the huge IN ((1),(2),...) lists Django was compiling for those
prefetches, which alone were about a fifth of the request. That branch
covers string, number, boolean, inet, macaddr, date and datetime, so it
is the bulk of the rows. The Server objects it used to fetch were only
ever dict keys.

The reverse branch now keys off sa.value_id instead of resolving the FK,
and _add_supernet_attribute() uses the host_server_id its own SQL
already returns.

In a query with hundreds and thousands of results this way the function
calls can be halved and runtime could be reduced by circa 40%.

Three places still key by Server on purpose: servers_by_related in
_add_related_attribute(), whose keys feed a hostname filter;
_get_join_results(); and the reverse branch, which stores sa.server as
an attribute value rather than using it as a key. All three are small
next to the main map.
The query materializer keys its per-server attribute dicts by Attribute
instances, so every lookup goes through Model.__hash__(). That is a
Python-level method which first calls _is_pk_set() and then reads the pk
property before hashing - three calls for what is, for this model, just
hash(self.attribute_id), since attribute_id is the primary key.

Those dicts are hit once per server per attribute: Hundreds of thousands of
times on a queries with thousand objects. The override returns the identical
value and skips the two extra calls. Equality stays Django's, so hash and eq
remain consistent.

The only behavioral difference is that an unsaved Attribute without an
attribute_id becomes hashable, which Django forbids on purpose. Here
attribute_id is a natural key assigned before save, so the case does not
arise.

I currently think this is safe to do but if it makes any problems feel free
to revert this commit is isolated from the other changes.
The generic branch of QueryMaterializer._add_attributes() built a model
instance for every attribute row - over a hundred thousand on a large
query - only to read server_id, attribute_id and value once each. An
instance costs Model.__init__() plus the pre_init and post_init signals;
that machinery was about 15% runtime on profiled requests.

Read the rows with values_list() instead. The per-type conversion
get_value() applied is now also available as a classmethod,
convert_db_value(), so the knowledge stays on the models: number keeps
its Decimal-to-int/float rule there and get_value() delegates to it,
boolean returns True as before since its table has no value column, and
relation raises NotImplementedError because its column is a server_id
that cannot become a Server without a query - the materializer resolves
those targets with one bulk query instead of a prefetch per chunk.

The branch moves into its own method, _add_stored_attributes(), since
the two type checks would otherwise push _add_attributes() past the
project's cyclomatic complexity limit. It clears the relation manager's
default prefetch of "value", which would run against the tuples and
fail, and is redundant given the bulk fetch anyway.

Inet values still pass through the netfields converter; values_list()
applies field converters like the model path does.

I was unsure whatever it is worth to make this change the LLM suggested
but found the way with the classmethod acceptable in readability vs
performance trade-off.
@kofrezo
kofrezo requested a review from brainexe September 22, 2026 15:31
@kofrezo kofrezo self-assigned this Sep 22, 2026
@kofrezo kofrezo added enhancement ai Code fully or partially AI generated. labels Sep 22, 2026
@kofrezo

kofrezo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

This code has been written with the assistance of LLMs but manually reviewed and tested. I find the changes meaningful and understandable. Two commits are questionable in thus as they make changes to the standard way Django works and can be dropped if they cause any problems.

@brainexe brainexe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

manual tests passed, code looks valid ✔️

@kofrezo
kofrezo merged commit 81a6373 into main Sep 23, 2026
5 checks passed
@kofrezo
kofrezo deleted the dk_query_materializer_perf branch September 23, 2026 08:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai Code fully or partially AI generated. enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants