Skip to content

among state machine enhancements #302

Description

@ojwb

I've been working on a new approach to implementing Snowball's substring...among.

This essentially encodes a state machine where each transition is either an O(1) multi-way dispatch on the next byte/character or a check that a particular string of bytes/characters follows (in UTF-8 it works in bytes; for fixed-width encodings it works in characters). This makes among O(1) in the number of strings rather than O(log(n)), and testing shows it's actually faster in practice for real-world among use. The size of the data tables is typically smaller than the old implementation on 64-bit machines, and roughly comparable on 32-bit machines. The generated C code is much more shared library friendly (the existing approach results in a lot of dynamic load time relocations).

This is now working well, and I just need to clean it up a bit and then merge it.

Once that's done, there are some further enhancements that could be made which I'm opening this ticket to keep track of. In no particular order:

  • Look at this approach for other target languages. For some it might work better to generate nested switch statements instead of having a tiny interpreter for the state-machine (this might be interesting to try for C too).
  • Find common subtrees in the state-machine and only store them once - e.g. in forwards mode, both of these end up allowing 'n' or 'ns': among ( 'ion' 'ions' 'ian' 'ians' ). This reduces the size of the data and so improves cache utilisation. Might need 2 passes, or some sort of interim data structure.
  • Track a threshold to include in the limit check at each step so we can stop when the string is too short for a match to be possible from where we are. This would be added in the first limit check and mean we would not need the additional limit check in the substring case (because this threshold would be >= the segment size). See min_length_match in the code (which is calculated but currently not used).
  • Currently N-way dispatch is done by a simple table with N entries, which is often fairly sparse (any bytes/characters which cause us not to match will be zero). We currently compactly encode cases where only one or two values can match, but we could potentially use some sort of cheap perfect hash (e.g. PEXT or similar operation) to reduce the number of entries we need to store in some other cases. (Note that this hash can conflate characters which take us to the same next state.)
  • Or support N-way with a gap in the middle? Many of the long ranges have just one big gap (e.g. between ASCII latin letters and those with diacritics).
  • Or support binary-chop for very sparse cases (it isn't O(1), but it could be limited to when there are < M non-zero entries to provide a complexity bound and/or its use could potentially be selected for rarely exercised cases based on profiling data)
  • After a segment, the next transition can't be another segment - can we take advantage of that? Could encode segment+N-way with segment sometimes empty. Might work against common-subtree sometimes. Also a segment longer than 255 bytes/characters would probably need encoding as multiple consecutive segments... (Consecutive segments can occur if we can exactly match part way through - e.g. backwards among ( 'soyad' 'ad' ) has segment ad then segment soy.)
  • Some groupings could be implemented by a symbol array lookup on an among N-way dispatch table (e.g. grouping of vowels vs an among transition which accepts a vowel). We could just opportunistically scan the generated among tables to find any data which is suitable - it doesn't actually have to be an N-way dispatch!
  • Calls to among gating functions are now generated after the call to find_among()/find_among_b() - that means we could inline their code like we do for explicit calls. A C/C++ compiler should be able to inline them for us now (whereas it couldn't for the old among implementation), but this inlining would help languages which don't have an optimiser which inlines code for us. Even for C/C++, it could enable turning some global snowball integer or boolean variables into locals.
  • gopast non-GROUPING could be encoded as an among table where the state machine can loop (it consumes a byte/character on each step so any looping is finite). Also goto and non-inverted cases, but that would require us to support the default (a) being a state transition and (b) advancing the cursor so it a bit more complex.
  • Consecutive or composed amongs could be encoded together into a single table (including an among gating function which is an action-less among); similarly for grouping checks in such positions.
  • Share identical tables between stemmers (e.g. stemmers which use only ASCII characters have identical tables for iso-8859-1 and UTF-8; some tables may even be the same across languages, though it seems none are currently). The groupings and string will also be the same for ASCII-only stemmers, so maybe this is really more about encoding variants...
  • We could save explicitly storing string literals which are substrings of segments in among tables.

Activity

  1. ojwb commented on Aug 18, 2026

    @ojwb
    MemberAuthor

    I've been testing the new among code with wide characters - this requires an extra short in each state's entry, plus segments take up approximately twice as much space (more precisely, segments take up half as much space when not using wide characters, but rounded up if the segment length is odd). This is enough extra to push the Step_2 among in serbian.sbl over 32768 shorts, and we make use of the sign bit to distinguish a return value from an offset to another state in the table.

    (It might seem like this is a situation where the new approach is woefully inefficient, but it actually generates smaller tables for these cases: on a 64-bit platform where pointer members can be 4-byte aligned for UTF-8 stemmers the existing approach generates an among table that's 48840 bytes for the array plus 10765 (maybe plus padding) for the strings which is 59605 bytes total, while the new approach generates a 32810 byte table with no extra for strings; for wide characters using a 32-bit wchar_t it looks like the existing approach needs 48840 + 10765*4 = 91900 vs 78648 for the new approach).

    The snowball compiler has long supported -w when generating C/C++; libstemmer doesn't expose this (more in #267) but it's not helpful to regress this.

    The offsets to states are all currently from the start of the table, mostly as that's slightly simpler to execute. They could be from the current state, though that probably means we'd need to reorder states within the table (currently they're emitted using a depth-first approach). That would also make encoding gopast non-grouping into the table harder - for a grouping with multi-byte UTF-8 we'd then need to encode a transition to a state earlier in the table. The widechars tables could differ in this way, but that seems extra complexity better avoided.

    So I think one of the table size reducing ideas above is the best answer.

  2. ojwb commented on Aug 18, 2026

    @ojwb
    MemberAuthor

    Merging common subtrees seems a good option. The leaf case looks fairly easy, and my quick hack at working out what it would save for serbian.sbl's among#2 gives 13234 words, which would reduce its among table to 32716 words, which is neatly just under the 32768 threshold.

    Update: the non-leaf case is easy too - we can check the segment/N-way/2-way we've just encoded and if it exactly matches one we've already added we can shrink the table to where it started and return the offset to the exact match instead. Offsets in the encoded block matching means the sub-trees match too (a bit like git commit hashes being calculated over data including the hash of parent commits ensuring a match on commit hash means a match on ancestry). I have implemented locally for segments so far and everything works!

    update2: Now merged to main.

  3. ojwb commented on Aug 26, 2026

    @ojwb
    MemberAuthor

    I looked at the stats for sparse ranges. In UTF-8 and single-byte encodings, the codeunits are bytes so the maximum range size is 256 (and the first 32 are control characters in most of those encodings so unlikely to feature).

    For wide characters we can get some much larger ranges though - here are all the ones >= 300 in size:

    +++ NWAY window 1600:65276 144 of 63677 0.2%: 0x640 0x64b 0x64c 0x64d 0x64e 0x64f 0x650 0x651 0x652 0x660 0x661 0x662 0x663 0x664 0x665 0x666 0x667 0x668 0x669 0xfe80 0xfe81 0xfe82 0xfe83 0xfe84 0xfe85 0xfe86 0xfe87 0xfe88 0xfe89 0xfe8a 0xfe8b 0xfe8c 0xfe8d 0xfe8e 0xfe8f 0xfe90 0xfe91 0xfe92 0xfe93 0xfe94 0xfe95 0xfe96 0xfe97 0xfe98 0xfe99 0xfe9a 0xfe9b 0xfe9c 0xfe9d 0xfe9e 0xfe9f 0xfea0 0xfea1 0xfea2 0xfea3 0xfea4 0xfea5 0xfea6 0xfea7 0xfea8 0xfea9 0xfeaa 0xfeab 0xfeac 0xfead 0xfeae 0xfeaf 0xfeb0 0xfeb1 0xfeb2 0xfeb3 0xfeb4 0xfeb5 0xfeb6 0xfeb7 0xfeb8 0xfeb9 0xfeba 0xfebb 0xfebc 0xfebd 0xfebe 0xfebf 0xfec0 0xfec1 0xfec2 0xfec3 0xfec4 0xfec5 0xfec6 0xfec7 0xfec8 0xfec9 0xfeca 0xfecb 0xfecc 0xfecd 0xfece 0xfecf 0xfed0 0xfed1 0xfed2 0xfed3 0xfed4 0xfed5 0xfed6 0xfed7 0xfed8 0xfed9 0xfeda 0xfedb 0xfedc 0xfedd 0xfede 0xfedf 0xfee0 0xfee1 0xfee2 0xfee3 0xfee4 0xfee5 0xfee6 0xfee7 0xfee8 0xfee9 0xfeea 0xfeeb 0xfeec 0xfeed 0xfeee 0xfeef 0xfef0 0xfef1 0xfef2 0xfef3 0xfef4 0xfef5 0xfef6 0xfef7 0xfef8 0xfef9 0xfefa 0xfefb 0xfefc
    +++ NWAY window 32:8205 10 of 8174 0.1%: 0x20 0x623 0x624 0x625 0x626 0x629 0x643 0x64a 0x6c1 0x200d
    +++ NWAY window 108:539 6 of 432 1.4%: 0x6c 0x72 0x74 0x76 0x103 0x21b
    +++ NWAY window 99:539 10 of 441 2.3%: 0x63 0x6c 0x6e 0x72 0x73 0x74 0x76 0x103 0x219 0x21b
    +++ NWAY window 97:537 6 of 441 1.4%: 0x61 0x69 0x6e 0x73 0x75 0x219
    +++ NWAY window 97:539 8 of 443 1.8%: 0x61 0x65 0x74 0x75 0x7a 0xe2 0x219 0x21b
    

    All of these would be reduced a lot by allowing two ranges with a gap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions