Re: TermInSetQuery storage: PrefixCodedTerms vs sorted BytesRef[]
"Govind Balaji via dev" <[email protected]> Tue, 12 May 2026 15:23:10 +0530
| Newsgroups | gmane.comp.jakarta.lucene.devel |
|---|---|
| Message-ID | <CABJ9OiaCQ7+zM1oAJE7cYD-MfzOKrEcWRrvWxz_6z2-YKvLEAA@mail.gmail.com> |
--000000000000839f7006519bd5d4 Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: quoted-printable Hi Greg, Thanks for looking into this. PR here with the new impl + JMH benchmark: https://github.com/apache/lucene/pull/16051. The JMH numbers are in the PR description. Regards, Govind Balaji S On Fri, May 8, 2026 at 9:30=E2=80=AFPM Greg Miller <[email protected]> wro= te: > 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-case= s > would prefer the memory efficient query representation. But that's just o= ne > 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]> wrote: > >> Hello Lucene devs, >> >> I'd like to surface a benchmark observation about TermInSetQuery's choic= e >> 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, an= d >> 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 ~9= 0% >> 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 a= s >> 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 b= y >> random access into a BytesRef[] instead of a streaming TermIterator. >> Conceptually this is closer to the pre-LUCENE-6350 storage layout. We >> also 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 >> being 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 ter= m >> that we chose to have indexed. Reported as speedup =3D TermInSetQuery ti= me / >> 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 >> the number of terms in TermInSetQuery > 30K. (Decreased by ~2% for queri= es >> with 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 >> of interest. >> >> Thanks, >> Govind Balaji S >> >> --000000000000839f7006519bd5d4 Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable <div dir=3D"ltr">Hi Greg,<br><div><br></div><div>Thanks for looking into th= is. PR here with the new impl=C2=A0+ JMH benchmark:=C2=A0<a href=3D"https:/= /github.com/apache/lucene/pull/16051">https://github.com/apache/lucene/pull= /16051</a>. The JMH numbers are in the PR description.</div><div><br></div>= <div>Regards,</div><div>Govind Balaji S</div></div><br><div class=3D"gmail_= quote gmail_quote_container"><div dir=3D"ltr" class=3D"gmail_attr">On Fri, = May 8, 2026 at 9:30=E2=80=AFPM Greg Miller <<a href=3D"mailto:gsmiller@g= mail.com">[email protected]</a>> wrote:<br></div><blockquote class=3D"g= mail_quote" style=3D"margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204= ,204,204);padding-left:1ex"><div dir=3D"ltr"><div dir=3D"ltr">Thanks for sh= aring this work Govind. This is interesting stuff!</div><div dir=3D"ltr"><b= r></div><div>My brief 2 cents: I suspect it's a somewhat atypical patte= rn 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 majorit= y 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 b= e great to publish your benchmarking in a PR! Let's capture this testin= g and have a discussion. Even if the community decides to stay with the cur= rent approach, you might want to consider adding your version of this query= in the sandbox module. That could provide a nice, community-supported opti= on for a more CPU efficient version of this query that users could opt into= .</div><div><br></div><div>Cheers,</div><div>-Greg</div><br><div class=3D"g= mail_quote"><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]= .org" target=3D"_blank">[email protected]</a>> wrote:<br></div><bloc= kquote class=3D"gmail_quote" style=3D"margin:0px 0px 0px 0.8ex;border-left:= 1px solid rgb(204,204,204);padding-left:1ex"><div dir=3D"ltr">Hello Lucene = devs,<br><br>I'd like to surface a benchmark observation about TermInSe= tQuery'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 numb= er of terms, and the reasoning behind.<div><div><br>## Background<br>Before= LUCENE-6350 (May 2015), TermsQuery (the predecessor of TermInSetQuery) sto= red its terms as a sorted byte[] with a parallel int[] of offsets, plus a c= ached int hashCode.=C2=A0<div>LUCENE-6350 replaced this shape with PrefixCo= dedTerms. The rationale recorded in the issue was "This will save ram = and cleanup a lot of the code."<br><br>## The workload where this show= s up<br>=C2=A0 - We run TermInSetQuery with a tens of thousands terms per q= uery.<br>=C2=A0 - The LRUQueryCache skips caching the query on most segment= s because they are below its per-leaf size threshold. In our workloads, thi= s is ~90% of segments (cumulatively a small fraction of docs). <br>=C2=A0 = =C2=A0 On those segments the query pays its full per-segment cost of unpack= ing and iterating the same prefix coded terms on every request.</div><div>= =C2=A0 - Our workloads are CPU-bound than memory-bound.<br><br>In this setu= p, the work that is independent of segment size becomes the dominant CPU co= st of the query on all those many small segments.=C2=A0</div><div>PrefixCod= edTerms.TermIterator.next loading delta-encoded bytes show up as hot in pro= files.<br><br>## What we tried<br>A reimplementation of TermInSetQuery whos= e only change is the storage shape of the term list.</div><div>Same Filtere= dTermsEnum ping-pong (accept / nextSeekTerm) - just driven by random access= into a BytesRef[] instead of a streaming TermIterator.=C2=A0</div><div>Con= ceptually this is closer to the pre-LUCENE-6350 storage layout. We also avo= id the packTerms() cost in the constructor with this.<br><br>We additionall= y 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[].</div><div>That is how the pre-6350 implement= ation got its fast equals as well. BytesRef[] is still redundant, but did n= ot want to mess around with the interfacing types too much and seemed okay.= <br><br>## Results<br>JMH microbenchmarks against Lucene's TermInSetQue= ry (Query construction + IndexSearcher.count(query)), on an in-memory index= with the query cache disabled.<br>We try a few different N terms in the Te= rmInSetQuery, with each term being a random 16 char hex. <br>For the benchm= ark, the index contains 50 segments and we vary what terms are indexed in e= ach segment too as follows.=C2=A0</div><div>Each indexed term is also a 16 = char hex, and we index 1 doc for each term that we chose to have indexed. R= eported as speedup =3D TermInSetQuery time / our impl time; >1 means our= impl is faster.<br><br><font face=3D"monospace">=C2=A0 Index 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<b= r>=C2=A0 each segment has exactly the same terms in the random 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 production (opensear= ch indexes with multiple shards per index), where this TermInSetQuery is on= ly 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 Term= InSetQuery > 30K. (Decreased by ~2% for queries with number of terms bet= ween 1k to 5k).<br><br>## What I'd like to ask</div><div>Is this patter= n of queries considered atypical and not worth the CPU at the cost of memor= y? Is this also a guiding principle in other parts of lucene?</div><div>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.<br><br>Happy to put a PR up with the JMH benc= hmark/the impl we tried if that's of interest.</div><div><br></div><div= >Thanks,</div><div>Govind Balaji S<br><br></div></div></div></div> </blockquote></div></div> </blockquote></div> --000000000000839f7006519bd5d4--