Re: TermInSetQuery storage: PrefixCodedTerms vs sorted BytesRef[]
Greg Miller <[email protected]> Fri, 8 May 2026 09:00:20 -0700
| Newsgroups | gmane.comp.jakarta.lucene.devel |
|---|---|
| Message-ID | <CANJ0CDqWXAx1Tuq=5N=nNuQUyTeQ0MxgC7G9tz_jFjrJLAyBMw@mail.gmail.com> |
--000000000000996af706515080d5 Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: quoted-printable Thanks for sharing this work Govind. This is interesting stuff! My brief 2 cents: I suspect it's a somewhat atypical pattern to have queries that are dominated by large cardinality term-in-set disjunctions. So my initial reaction to this is that I suspect the majority of use-cases would prefer the memory efficient query representation. But that's just one opinion. That said, it would be great to publish your benchmarking in a PR! Let's capture this testing and have a discussion. Even if the community decides to stay with the current approach, you might want to consider adding your version of this query in the sandbox module. That could provide a nice, community-supported option for a more CPU efficient version of this query that users could opt into. Cheers, -Greg On Thu, May 7, 2026 at 3:20=E2=80=AFAM Govind Balaji via dev <[email protected]= ache.org> wrote: > Hello Lucene devs, > > I'd like to surface a benchmark observation about TermInSetQuery's choice > of PrefixCodedTerms as the in-memory term storage, and am curious if the > community is aware of this RAM-CPU tradeoff at large number of terms, and > the reasoning behind. > > ## Background > Before LUCENE-6350 (May 2015), TermsQuery (the predecessor of > TermInSetQuery) stored its terms as a sorted byte[] with a parallel int[] > of offsets, plus a cached int hashCode. > LUCENE-6350 replaced this shape with PrefixCodedTerms. The rationale > recorded in the issue was "This will save ram and cleanup a lot of the > code." > > ## The workload where this shows up > - We run TermInSetQuery with a tens of thousands terms per query. > - The LRUQueryCache skips caching the query on most segments because > they are below its per-leaf size threshold. In our workloads, this is ~90= % > of segments (cumulatively a small fraction of docs). > On those segments the query pays its full per-segment cost of > unpacking and iterating the same prefix coded terms on every request. > - Our workloads are CPU-bound than memory-bound. > > In this setup, the work that is independent of segment size becomes the > dominant CPU cost of the query on all those many small segments. > PrefixCodedTerms.TermIterator.next loading delta-encoded bytes show up as > hot in profiles. > > ## What we tried > A reimplementation of TermInSetQuery whose only change is the storage > shape of the term list. > Same FilteredTermsEnum ping-pong (accept / nextSeekTerm) - just driven by > random access into a BytesRef[] instead of a streaming TermIterator. > Conceptually this is closer to the pre-LUCENE-6350 storage layout. We als= o > avoid the packTerms() cost in the constructor with this. > > We additionally keep the term bytes in a single contiguous byte[] > (length-prefixed VInts, BytesRef[] entries are zero-copy views into it), > and have equals/hashCode operate solely on that byte[]. > That is how the pre-6350 implementation got its fast equals as well. > BytesRef[] is still redundant, but did not want to mess around with the > interfacing types too much and seemed okay. > > ## Results > JMH microbenchmarks against Lucene's TermInSetQuery (Query construction + > IndexSearcher.count(query)), on an in-memory index with the query cache > disabled. > We try a few different N terms in the TermInSetQuery, with each term bein= g > a random 16 char hex. > For the benchmark, the index contains 50 segments and we vary what terms > are indexed in each segment too as follows. > Each indexed term is also a 16 char hex, and we index 1 doc for each term > that we chose to have indexed. Reported as speedup =3D TermInSetQuery tim= e / > our impl time; >1 means our impl is faster. > > Index content | N =3D > 300 | N =3D 3k | N =3D 30k > each segment has exactly the same terms in the random query | 1.30x > | 1.31x | 1.22x > Only 2 of N query terms in each segment, most seeks miss | 2.11x > | 3.41x | 3.74x > 50k random (independent of the query) terms in each segment | 1.05x > | 1.15x | 1.32x > > In production (opensearch indexes with multiple shards per index), where > this TermInSetQuery is only a subquery of our typical query, > we saw the overall mean latency decrease by ~22% with this change when th= e > number of terms in TermInSetQuery > 30K. (Decreased by ~2% for queries wi= th > number of terms between 1k to 5k). > > ## What I'd like to ask > Is this pattern of queries considered atypical and not worth the CPU at > the cost of memory? Is this also a guiding principle in other parts of > lucene? > Or is there a quantified typical values for CPU-memory tradeoff? Asking > out of curiousity whether we should be tweaking more stuff similarly, > internally, for our CPU-bound workloads. > > Happy to put a PR up with the JMH benchmark/the impl we tried if that's o= f > interest. > > Thanks, > Govind Balaji S > > --000000000000996af706515080d5 Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable <div dir=3D"ltr"><div dir=3D"ltr">Thanks for sharing this work Govind. This= is interesting stuff!</div><div dir=3D"ltr"><br></div><div>My brief 2 cent= s: I suspect it's a somewhat atypical pattern to have queries=C2=A0that= are dominated by large cardinality term-in-set disjunctions. So my initial= reaction to this is that I suspect the majority of use-cases would prefer = the memory efficient query representation. But that's just one opinion.= </div><div><br></div><div>That said, it would be great to publish your benc= hmarking in a PR! Let's capture this testing and have a discussion. Eve= n if the community decides to stay with the current approach, you might wan= t to consider adding your version of this query in the sandbox module. That= could provide a nice, community-supported option for a more CPU efficient = version of this query that users could opt into.</div><div><br></div><div>C= heers,</div><div>-Greg</div><br><div class=3D"gmail_quote gmail_quote_conta= iner"><div dir=3D"ltr" class=3D"gmail_attr">On Thu, May 7, 2026 at 3:20=E2= =80=AFAM Govind Balaji via dev <<a href=3D"mailto:[email protected]"= >[email protected]</a>> wrote:<br></div><blockquote class=3D"gmail_q= uote" style=3D"margin:0px 0px 0px 0.8ex;border-left-width:1px;border-left-s= tyle:solid;border-left-color:rgb(204,204,204);padding-left:1ex"><div dir=3D= "ltr">Hello Lucene devs,<br><br>I'd like to surface a benchmark observa= tion about TermInSetQuery's choice of PrefixCodedTerms as the in-memory= term storage, and am curious if the community is aware of this RAM-CPU tra= deoff at large number of terms, and the reasoning behind.<div><div><br>## B= ackground<br>Before LUCENE-6350 (May 2015), TermsQuery (the predecessor of = TermInSetQuery) stored its terms as a sorted byte[] with a parallel int[] o= f offsets, plus a cached int hashCode.=C2=A0<div>LUCENE-6350 replaced this = shape with PrefixCodedTerms. The rationale recorded in the issue was "= This will save ram and cleanup a lot of the code."<br><br>## The workl= oad where this shows up<br>=C2=A0 - We run TermInSetQuery with a tens of th= ousands terms per query.<br>=C2=A0 - The LRUQueryCache skips caching the qu= ery on most segments because they are below its per-leaf size threshold. In= our workloads, this is ~90% of segments (cumulatively a small fraction of = docs). <br>=C2=A0 =C2=A0 On those segments the query pays its full per-segm= ent cost of unpacking and iterating the same prefix coded terms on every re= quest.</div><div>=C2=A0 - Our workloads are CPU-bound than memory-bound.<br= ><br>In this setup, the work that is independent of segment size becomes th= e dominant CPU cost of the query on all those many small segments.=C2=A0</d= iv><div>PrefixCodedTerms.TermIterator.next loading delta-encoded bytes show= up as hot in profiles.<br><br>## What we tried<br>A reimplementation of Te= rmInSetQuery whose only change is the storage shape of the term list.</div>= <div>Same FilteredTermsEnum ping-pong (accept / nextSeekTerm) - just driven= by random access into a BytesRef[] instead of a streaming TermIterator.=C2= =A0</div><div>Conceptually this is closer to the pre-LUCENE-6350 storage la= yout. We also avoid the packTerms() cost in the constructor with this.<br><= br>We additionally keep the term bytes in a single contiguous byte[] (lengt= h-prefixed VInts, BytesRef[] entries are zero-copy views into it), and have= equals/hashCode operate solely on that byte[].</div><div>That is how the p= re-6350 implementation got its fast equals as well. BytesRef[] is still red= undant, but did not want to mess around with the interfacing types too much= and seemed okay.<br><br>## Results<br>JMH microbenchmarks against Lucene&#= 39;s TermInSetQuery (Query construction + IndexSearcher.count(query)), on a= n in-memory index with the query cache disabled.<br>We try a few different = N terms in the TermInSetQuery, with each term being a random 16 char hex. <= br>For the benchmark, the index contains 50 segments and we vary what terms= are indexed in each segment too as follows.=C2=A0</div><div>Each indexed t= erm is also a 16 char hex, and we index 1 doc for each term that we chose t= o have indexed. Reported as speedup =3D TermInSetQuery time / our impl time= ; >1 means our impl is faster.<br><br><font face=3D"monospace">=C2=A0 In= dex content=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0| N =3D 300 | N =3D 3k |=C2=A0= N =3D 30k=C2=A0<br>=C2=A0 each segment has exactly the same terms in the r= andom query=C2=A0 =C2=A0 =C2=A0| 1.30x=C2=A0 =C2=A0| 1.31x=C2=A0 | =C2=A01.= 22x<br>=C2=A0 Only 2 of N query terms in each segment, most seeks miss=C2= =A0 =C2=A0 =C2=A0 =C2=A0 | 2.11x=C2=A0 =C2=A0| 3.41x=C2=A0 | =C2=A03.74x<br= >=C2=A0 50k random (independent of the query) terms in each segment =C2=A0 = =C2=A0 | 1.05x=C2=A0 =C2=A0| 1.15x=C2=A0 | =C2=A01.32x</font><br><br>In pro= duction (opensearch indexes with multiple shards per index), where this Ter= mInSetQuery is only a subquery of our typical query,=C2=A0</div><div>we saw= the overall mean latency decrease by ~22% with this change when the number= of terms in TermInSetQuery > 30K. (Decreased by ~2% for queries with nu= mber of terms between 1k to 5k).<br><br>## What I'd like to ask</div><d= iv>Is this pattern of queries considered atypical and not worth the CPU at = the cost of memory? Is this also a guiding principle in other parts of luce= ne?</div><div>Or is there a quantified typical values for CPU-memory tradeo= ff? Asking out of curiousity whether we should be tweaking more stuff simil= arly, internally, for our CPU-bound workloads.<br><br>Happy to put a PR up = with the JMH benchmark/the impl we tried if that's of interest.</div><d= iv><br></div><div>Thanks,</div><div>Govind Balaji S<br><br></div></div></di= v></div> </blockquote></div></div> --000000000000996af706515080d5--