-
-
Notifications
You must be signed in to change notification settings - Fork 53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Integrate combinable DFA capture resolution for our PCRE dialect regexes, part 1. #440
base: sv/integration-target--combinable-DFA-capture-resolution
Are you sure you want to change the base?
Conversation
Remove include/adt/mappingset.h, it's no longer used.
This and src/libfsm/internal.h's EXPENSIVE_CHECKS should move to a common place later.
These symbols are exported in the library.
These rely on either direct FSM construction or setting captures via a capture path, but captures are now implemented by a completely different mechanism. New tests will be added with the capture code in a future commit.
This is a big commit, unfortunately difficult to break apart further due to interface changes, metadata being passed through whole-FSM transformations, and so on. Sorry about that. - Delete code related to capture action metadata on edges. That approach made FSM transformations (determinisation, minimisation, etc.) considerably more expensive, and there were some corner cases that I wasn't able to get working correctly. - Switch to a somewhat simpler method, adapted from Russ Cox's "Regular Expression Matching: the Virtual Machine Approach". Since the capture resolution metadata (an opcode program for a virtual machine) is associated with individual end states, this combines cleanly when multiple regexes are unioned into a single large DFA that matches them all at once. - Add lots of capture regression tests, mostly from using libfsm's `fsm_generate_matches` and a fuzzer to compare behavior against PCRE. This brought many, many obscure cases to light. The updated fuzzer harness will appear in a later commit.
Add a couple more execution modes to the fuzzer test harness. In particular, add support for comparing match and capture behavior against PCRE's -- because this depends on linking with libpcre, it's disabled by defalut. Set `PCRE_CMP` in the Makefile to enable it, or pass it as a build argument.
I'm adding this because many of the existing tests under `tests/pcre-anchor/` and so on contain regexes that would now be rejected as unsupported in combination with captures, but they are testing cases unrelated to capturing.
There are several tests that have nothing to do with captures, capture behavior is tested directly with `tests/captures/`.
We currently get the wrong capture result for it.
There were modifications to metadata associated with end states on both sides. A few other changes will appear in separate commits. Conflicts: include/fsm/fsm.h src/libfsm/consolidate.c src/libfsm/endids.c src/libfsm/merge.c
Now instead of exposing exactly how many captures the fsm has, we keep track of the ceiling of the count, to track how large the capture buffer needs to be. We could add this back if fsm_capture_count gets re-added.
Previously we doubled the buffer if it wasn't large enough, but the next endid array may be > twice the size of the old buffer, so we need to keep expanding until it's large enough. We weren't saving the updated array size, so this could potentially lead to repeated doubling and eventually allocation failures. Also, change the assert for fsm_getendids's result -- if we get to that point, it should always be found, not just not an insufficient space error.
determinise: It's not possible to find a cached result in the hash table without allocating a to-set buffer first, so assert that it will be non-NULL. fsm_findmode: This should never be used on a state without edges. vm/v1.c and vm/v2.c: Free allocated return value on error.
For each pattern 0..n that will be combined, set an endid on them. Then, generate inputs that match, and check that the endid result on the single and combined FSMs are consistent.
In minimise.c, `split_ecs_by_end_metadata` does a first pass splitting ECs based on their end data (like the name says). This sets which end metadata should prevent states from starting out in the same EC, effectively which states can/cannot be combined once minimisation's analysis is done. Previously, distinct sets of end IDs would keep states from merging, but if epsilon removal and determinisation have led to end states with distinct sets of end IDs, that alone shouldn't prevent them from merging later -- the same end state just becomes associated with all those end IDs. We do prevent states with distinct capture ID sets from merging, but that's because of a few special cases like "^a(b)c$" and "^a(b*)c$", where combining partially overlapping regexes' end states could lead to false positives in the capture result. Note: I added checking the program IDs (which was missing) to `same_end_metadata`. This seems correct to me, but at the moment I can't think of any test inputs that would lead to the same sets of capture IDs but distinct sets of program IDs. I will see if fuzzing can find any. This is tested by tests/endids/endids2_union_many_endids.c and the new multi test in tests/capture/capture_test_case_list.c.
There were several unused store warnings about values that were only set for logging, either `(void)x` them out or restructure the code so that their scope is more limited. Remove `next_offset` from `populate_solution`, since that isn't being used at all. IIRC it was added before some of the path sharing details made it unnecessary.
Also compare in the other direction, generating matching inputs from the combined DFA and then check that individual regexes's captures still match as expected.
This was set up to handle edge cases like `^(($)|x)+$` where there is an alt with some nullable cases with anchors, surrounded by + repetition, which turned up during fuzzing. This is an obscure edge case, it is proving very difficult to handle correctly, and there is probably little value in actually doing so. Now we are flagging it as an unsupported PCRE construct in ast_analysis.c's analysis_iter_repetition, so the code using repeated_alt_backpatches is unreachable. Just remove it.
Strictly speaking this shouldn't include nodes that have been flagged with `AST_FLAG_UNSATISFIABLE`. I have re-fuzzed with this changed and it does not seem to have introduced any new issues. `active_node` is only used in one place.
Rather than commenting throughout to note that .cont is the greedy branch and .new is non-greedy, just rename them.
Addressed some merge conflicts to fuzz/target.c.
The description says "Return where an item would be, if it were inserted", but it was returning the last element <= rather than the first element >=, then the call to `state_set_cmpval` later was shifting i by 1 for that specific case. Handle it correctly inside the search function instead. Two other all call sites need to check whether the result refers to the append position (one past the end of the array) before checking `set->a[i] == state`, update them. Add a fast path upfront: It's VERY common to append states in order to the state array, so before we binary search each first compare against the last entry (unless empty).
In -O0 this can become pretty expensive (~25% of overall runtime for `time ./re -rpcre -C '^[ab]{0,2000}$'`), but when built with -O3 very little overhead remains. I'm adding this comment because every time I see this it seems to me like it should have `EXPENSIVE_CHECKS` around it, but profiling is telling me it really doesn't matter.
This is a major hotspot when doing epsilon removal over large runs of potentially skipped states (as might appear from `^[ab]{0,2000}$`). Add a fast path for appending, which is also very common. Extract the edge set destination search into its own function, `find_state_position`, and add a `#define` to switch between linear search, binary search, or calling both and comparing the result. I will remove linear search in the next commit, but am checking this in as an intermediate step for checking & benchmarking.
When I run `time ./re -rpcre -C '^[ab]{0,2000}$'` locally for -O3: - linear search: 2.991s - binary search: 1.521s
After the other changes in this PR, calls to qsort from `sort_and_dedup_dst_buf` are one of the largest remaining hotspots in the profile. We can often avoid calling qsort, though: - If there is <= 1 entry, just return, it's sorted. - Otherwise, first do a sweep through the array noting the min and max values. Unless there is a huge range between them, it's much faster to build a bitset from them in a small (max 10KB) stack-allocated array and then unpack the bitset (now sorted and unique). Only the needed portion of the array is initialized. I have not done a lot of experimentation to find a cutoff point where the bitset becomes slower than qsort (it may be much larger), I picked 10KB because it's likely to be safe to stack-allocate. I tried changing the bitset unpacking to use an 8 or 16 bit mask and jump forward faster through large sub-word ranges of 0 bits, but any improvement was lost among random variation, so I decided it wasn't worth the extra complexity. We already skip whole words that are 0.
If min and max are exactly 64 states apart the upper value was getting silently dropped due to an incorrect `words` value here. One of the patterns in the PCRE suite triggers this: ./re -rpcre '(?:c|d)(?:)(?:aaaaaaaa(?:)(?:bbbbbbbb)(?:bbbbbbbb(?:))(?:bbbbbbbb(?:)(?:bbbbbbbb)))' "caaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" This should match, but did not.
determinise: It's not possible to find a cached result in the hash table without allocating a to-set buffer first, so assert that it will be non-NULL. fsm_findmode: This should never be used on a state without edges. vm/v1.c and vm/v2.c: Free allocated return value on error.
There were merge conflicts, which had to do with the return type of idmap_iter's callback -- on `main` it was `void`, on the branch it was `int` and indicated whether iterations should continue or halt. I picked the `int` form, and updated `idmap_iter` so that it was actually checked, because it probably doesn't make sense to continue iterating when earlier steps have errored out.
I've merged the current |
MODE_REGEX, | ||
MODE_REGEX_SINGLE_ONLY, | ||
MODE_REGEX_MULTI_ONLY, | ||
MODE_IDEMPOTENT_DET_MIN, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add these new modes to ci.yml too please? where currently we have:
mode: [ m, p, d ]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated in 239927c.
|
||
static int | ||
compare_with_pcre(const char *pattern, struct fsm *fsm) | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fantastic
/* This triggers an "unreached" assertion in the parser. | ||
* It's already been reported (issue #386), but once the | ||
* fuzzer finds it, it will report it over and over. | ||
* Exit here so that the fuzzer considers it uninteresting. */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 'd' (default) no longer exists, it's now 'r' - add 's', 'i', 'M'
There's extra error codes in #440 for regexes that aren't UNSATISFIABLE per se, but depend on particular corner cases in PCRE that probably aren't worth supporting in an automata-based implementation. Add a test case for one, tests/pcre/in48.re: ^a|$[^x]b* This is a tricky one to handle properly; according to PCRE it should match either "a<anything...>" OR "\n", but nothing else. The newline match is because $ is a non-input-consuming check that evaluation is either at the end of input, or at a newline immediately before the end. In this case `$[^x]b*` matches exactly one newline; it's equivalent to "$\n". This probably isn't worth supporting, but we can detect cases where a potential newline match appears after a $ and reject them as an unsupported PCRE behavior.
There's extra error codes in #440 for regexes that aren't UNSATISFIABLE per se, but depend on particular corner cases in PCRE that probably aren't worth supporting in an automata-based implementation. Add a test case for one, tests/pcre/in48.re: ^a|$[^x]b* This is a tricky one to handle properly; according to PCRE it should match either "a<anything...>" OR "\n", but nothing else. The newline match is because $ is a non-input-consuming check that evaluation is either at the end of input, or at a newline immediately before the end. In this case `$[^x]b*` matches exactly one newline; it's equivalent to "$\n". This probably isn't worth supporting, but we can detect cases where a potential newline match appears after a $ and reject them as an unsupported PCRE behavior.
There's extra error codes in #440 for regexes that aren't UNSATISFIABLE per se, but depend on particular corner cases in PCRE that probably aren't worth supporting in an automata-based implementation. Add a test case for one, tests/pcre/in48.re: ^a|$[^x]b* This is a tricky one to handle properly; according to PCRE it should match either "a<anything...>" OR "\n", but nothing else. The newline match is because $ is a non-input-consuming check that evaluation is either at the end of input, or at a newline immediately before the end. In this case `$[^x]b*` matches exactly one newline; it's equivalent to "$\n". This probably isn't worth supporting, but we can detect cases where a potential newline match appears after a $ and reject them as an unsupported PCRE behavior.
This adds an extra compilation step for PCRE regexes, generating programs for a group capture resolution abstract machine. The implementation builds on the approach described in "Regular Expression Matching: the Virtual Machine Approach", with adaptations to reduce the runtime memory footprint, handle some edge cases the same way PCRE does, explicitly detect some other PCRE edge cases and reject them as unsupported, and continue supporting group captures as the resulting DFAs are combined with other DFAs into a single DFA that matches them all in one pass
I have fuzzed this code quite a lot, both comparing group capture results to PCRE's and comparing libfsm's handling of individual regexes' DFAs with the DFA produced my combining them. Many of the test cases in
tests/capture/capture_test_case_list.c
came from fuzzing. My working branch diverged frommain
for a while during this work, in particular the metadata associated with end states, but after integrating upstream changes I fuzzed it further. I think I integrated things properly, but it's something to be mindful of during review. Some unrelated issues discovered during fuzzing have already been posted as separate PRs.Worst-case memory usage is proportional to the size of the capvm program's length (known at compile time) and the input length -- we record a bit for each branch taken while evaluating the regex (e.g. either greedily jumping to repeat a subexpression or non-greedily advancing in the regex), so memory usage will slowly increase as inputs are evaluated. Common path prefixes are shared between threads, and the total thread count (and divergence) is bounded by the size of the opcode program. As a special case, a long path prefix of all zero bits is collapsed down to a counter; this usually happens because of an unanchored start loop, where each zero bit represents a path continuing to advance through the input without starting the match yet.
This PR targets an integration branch because some work is not yet complete:
^(()($)|x)+$
. These are probably not worth the trouble to support. It also doesn't support the\z
operator yet.re
aren't enabled yet, such as capture resolution for multiple files, and there
docs need updating for some new flags.