perf(where): remove per-event interpretive overhead from the MQL predicate tree - #16
Open
1linkovdim wants to merge 1 commit into
Open
perf(where): remove per-event interpretive overhead from the MQL predicate tree#161linkovdim wants to merge 1 commit into
1linkovdim wants to merge 1 commit into
Conversation
…e tree
A tagging stage matches every event against every active query, so
Query.matches is the hottest code path in MQL: fleet profiles of the
Mantis corekaas tagging stages put 10.75% of agent CPU inside it, of
which 4.03% is Clojure interpretive plumbing rather than the comparison
being asked for. This removes that plumbing. None of it changes what a
query means.
Four things were happening once per event which only need to happen once
per query:
1. with-meta on a function. On the JVM clojure.lang.AFunction.withMeta
cannot tag in place; it returns an anonymous AFunction$1 extends RestFn
whose doInvoke calls applyTo. Every tagged operand therefore allocates
an ArraySeq and walks invoke -> RestFn.invoke -> doInvoke -> applyTo ->
applyToHelper before reaching the real function. Both the operand
constructors and the where predicates were tagged this way. Operands
need their metadata (select reads :name), so they now carry it on a
MetaFn deftype which implements IFn directly and IObj for round
tripping. The {:clause :where} tag on the predicates is read by nothing
-- util/ops->clause-map is its only consumer and has no callers -- so it
is simply dropped.
2. Operator dispatch. check-predicate decided MQL's nil policy per event by
comparing the operator against the = and not= vars. The operator is
fixed when the query is compiled, so the policy is now resolved there.
check-predicate is left in place, unused by the compiler but still
public.
3. The fn? tests on each operand, likewise fixed at compile time.
4. property->fn built a one element key seq and reduced over it for every
lookup. The single property case, which is the overwhelming majority,
now closes over the key and calls properties/get directly, and
properties/get itself tests for List and RandomAccess with two literal
instance? forms instead of (every? ... [List RandomAccess]), which was
allocating a vector and running a seq traversal per lookup.
Behaviour is pinned by test-where-pred, which compares the compiled
predicates against a reference implementation built on check-predicate
across every comparison operator and the nil, absent property and
non-boolean-result cases, and checks that operand metadata still round
trips.
Measured with a JMH harness driving Query.matches through
io.mantisrx.mql.jvm.interfaces.server/make-query; the harness is not
committed, the numbers and the harness source are in the pull request
description.
1linkovdim
force-pushed
the
perf/where-predicate-tree
branch
from
August 20, 2026 14:58
4b87f2f to
b840128
Compare
1linkovdim
marked this pull request as ready for review
August 20, 2026 15:53
Andyz26
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
MQLQuery.matchesis invoked once per event, per active query, per tagging stage. In a merged fleetwide CPU profile of the Mantis agent fleet, the corekaasAbstractAckableTaggingStageaccounts for 15.00% of agent CPU, of which 10.75% is insideMQLQuery.matches. Splitting the self time inside that subtree:java.util.regex(actual matching work)That second row is the target here. It is not the comparison a query asks for — it is trampolining, seq allocation, per-event
fn?tests and per-event operator dispatch inside the compiled predicate tree. It is also disjoint from #15, which attacks the regex row by rewriting.*(a|b).*patterns intocontainschecks; the two changes compose.Nothing here changes what a query means.
What was happening once per event that only needs to happen once per query
1.
with-metaon a function. On the JVM,clojure.lang.AFunction.withMetacannot tag in place. It returns an anonymousAFunction$1 extends RestFnwhose entire body is:So every call to a tagged function goes
invoke(datum)→RestFn.invoke→ allocate anArraySeq→doInvoke→AFn.applyTo→AFn.applyToHelper→ the real function. Five frames and an allocation, per operand, per node, per event. In the profile this shows up asRestFn.invoke11.28% cumulative,AFunction$1.doInvoke10.46%,AFn.applyToHelper10.37%.Both the operand constructors in
operands.cljcand the predicates inwhere.cljcwere tagged this way.select.cljcreads:name,:as,:distinctoff them — so they now carry it on a smallMetaFndeftype (newio.mantisrx.mql.fnmeta) that implementsIFndirectly andIObjfor round-tripping.meta,with-metaandfn?behave exactly as before (MetaFnimplements theclojure.lang.Fnmarker interface, which is whatclojure.core/fn?tests).{:clause :where}tag on the predicates is read by nothing. Its only consumer isutil/ops->clause-map, which has no callers anywhere in the repo. It is simply dropped.2. Operator dispatch.
check-predicatedecided MQL's nil policy per event by comparing the operator function against the=andnot=vars. The operator is fixed when the query compiles, so the policy is resolved there instead.check-predicateitself is left in place, public and unchanged, for API compatibility — the compiler just no longer calls it.3. The
fn?tests on each operand, likewise resolved at compile time.4.
property->fnbuilt a one-element key seq andreduced over it for every lookup.properties/get-inis 2.95% of fleet CPU cumulative, essentially all of it reached fromproperty->fn, and 1.83% of fleet CPU isreduce/seq machinery wrapped around a single map lookup. The single-property case — the overwhelming majority of real queries — now closes over the key and callsproperties/getdirectly.properties/getitself now tests forListandRandomAccesswith two literalinstance?forms rather than(every? #(instance? % m) [List RandomAccess]), which was allocating a vector and running a seq traversal on every lookup.Predicate results are deliberately not coerced to boolean:
==+and==*return the truthy value fromsome, andserver.clj'smake-matcheralready wraps the tree in(comp boolean ...).Correctness
New
mql-jvm/src/test/clojure/io/mantisrx/mql/test_where_pred.cljpins behaviour by differential comparison: it keeps a reference implementation of the oldbinary-expr->predbuilt oncheck-predicate, and asserts the new compiled predicate agrees with it across every comparison operator (=,==,!=,<>,<,>,<=,>=) crossed with nil operands, absent properties and constant operands — plusstar-binary-expr->pred, non-boolean==+/==*results, operand metadata round-tripping (includingapply,map, and re-tagging throughwith-meta), nested and indexed property lookups, and end-to-endwhereclauses througheval-mql.(main is 50 tests / 97 assertions before the new namespace.)
Benchmark
No benchmark sources are committed — the harness below is throwaway scaffolding, and I did not want to add a JMH plugin to this build on the strength of one change. It is reproduced in full here so the numbers can be checked.
beforeis this harness run againstmain;afteris this branch. Run with-f 2 -wi 3 -i 5 -r 1s -w 1s -prof gcon Zulu JDK 17.0.17, JMH 1.36, on a laptop;±is JMH's 99.9% confidence interval andB/opisgc.alloc.rate.norm.equality—country == 'US'conjunction— 3 termsandeddisjunction— 3 termsorednestedProperty—e['request']['path'] == ...regexMatch—path ==~ /.*api.*ping/35–67% faster and 2–4.4× less garbage per matched event. Note
regexMatchretains 256 B/op — that is the regex work itself, which is #15's territory, not this change's.Caveat: laptop numbers, JMH auto-detected compiler blackholes, and shorter iteration counts than you would want for a final figure.
How to reproduce
Two things are worth knowing before wiring this up:
MQLServerAOT class. Its:gen-classuses the default-prefix, so the staticparse/parsesstubs look forserver/-parse, whichserver.cljdoes not define; calling them throwsUnsupportedOperationException. The working entry point is the Clojure fnio.mantisrx.mql.jvm.interfaces.server/make-query, reached throughclojure.java.api.Clojure.Query, and its matcher accepts everything. My firstregexMatchrun measured 9 ns/op because I had written=~; MQL's regex operator is==~. The harness therefore asserts at setup that each query accepts a populated datum and rejects an empty map, so a parse failure cannot masquerade as a fast benchmark.Apply this to the build —
me.champeau.jmh:jmh-gradle-plugin:0.7.2on the root buildscript classpath, plugin and config inmql-jvm:Then
mql-jvm/src/jmh/java/io/mantisrx/mql/MQLMatchBenchmark.java:Build on JDK 17 (Gradle 7.6.6 here does not accept 25) and run
./gradlew :mql-jvm:jmh. To diff two revisions, build:mql-jvm:jmhJaron each and run the two jars directly:java -jar build/libs/mql-jvm-*-jmh.jar -f 2 -wi 3 -i 5 -r 1s -w 1s -prof gc -rf json.Notes for reviewers
check-predicate,where-clause->fn,having-clause->fn,search-condition->predandboolean-term->predare unchanged.fnmeta.cljcis a.cljcwith a:cljsbranch that falls straight back towith-meta, so the ClojureScript build is unaffected.with-metacalls inoperands.cljc(property-with-as->fn,distinct-operand->property) are intentionally left as-is: they re-tag an already-wrappedMetaFn, so they go throughIObj.withMetaand keep the fast path.--max-nodestruncation). Worth pinning one window before comparing the two.