- Rust
regexcrate는 수년간의 재작성 끝에 내부 엔진 상당 부분을 별도 버전의regex-automatacrate API로 공개했고,regex 1.9에서 전환이 완료됨 - 재작성은 검색 전략의 조합 어려움, 내부 엔진별 테스트 난이도,
RegexSet보다 세밀한 다중 패턴 API 요구, 완전 컴파일 DFA 공유 필요에서 출발함 regex-automata는Ast → Hir → 리터럴 추출/Prefilter → Thompson NFA → PikeVM·BoundedBacktracker·one-pass DFA·DFA·lazy DFA → meta engine흐름으로 정규식 처리를 구성함- 성능은 가능한 한 리터럴 검색과 DFA 계열 엔진을 먼저 쓰고, 캡처 그룹이나 예외 상황에서는
PikeVM·bounded backtracker·one-pass DFA로 보완하는 방식으로 확보함 - 공개 API와 재사용 가능한 추상화는 테스트와 실험을 쉽게 만들었지만 코드량, 바이너리 크기, 컴파일 시간이 늘어났고, 완전 컴파일 DFA는 opt-in으로 두며
regex-lite가 경량 대안으로 추가됨
regex 재작성과 regex-automata 공개
- Rust의
regexcrate는 내부 조합을 개선하고 최적화를 더 쉽게 추가하면서 정확성을 유지하기 위해 수년간 재작성됨 - 이 과정에서
regex-automata가 만들어졌고,regexcrate 내부 구현 상당 부분을 별도 API로 노출함 regex-automata는 정규식 라이브러리 내부를 이 정도로 별도 버전 라이브러리로 공개한 첫 사례로 소개됨regex 1.9는 2023년 7월 5일 릴리스되며 이 재작성을 완료함- Rust 프로그래머와 유한 오토마타 기반 정규식 엔진 구현에 관심 있는 사람을 대상으로 하며, 정규식 경험을 전제로 함
재작성 이전의 문제들
- 기존
regexcrate는 RE2 전통을 따라 여러 검색 전략을 내부적으로 사용했지만, 전략들이 유기적으로 추가되며 조합이 어려워짐PikeVM은 최초 전략으로 설계되어 lazy DFA와 조합할 때 필요한 부분 슬라이스 검색 시작·중단 처리가 부족했음- 어떤 정규식에 어떤 전략이 쓰이는지 추론하기 어려웠음
- 여러
match표현식이 같은 로직을 반복 구현해 동기화가 어긋나기 쉬웠음 - Aho-Corasick만 쓰면 되는 정규식에서도 사용하지 않을 Thompson NFA가 불필요하게 만들어질 수 있었음
- 내부 엔진 테스트도 까다로웠음
- 공개 API는 하나의 정규식 엔진처럼 보이지만 내부에는 여러 전략이 있고, 같은 입력에서 동일하게 동작해야 함
regex 1.9이전에는 내부 전략이 공개 API가 아니어서 각 엔진을 독립적으로 테스트하기 어려웠음- 기존 테스트는 내부 API 노출, 문서화되지 않은
From구현, 매크로, 엔진별 테스트 타깃에 의존하는 해킹에 가까운 구조였음
- 여러 니치 API 요청도 기존 API 표면에 넣기 애매했음
RegexSet은 어떤 패턴이 haystack 어디엔가 매치됐는지만 알려주고, 매치 오프셋이나 캡처 그룹 오프셋은 제공하지 않음- 패턴에
^를 넣지 않고 anchored 검색을 실행하는 기능이 필요했음 - 검색 중 내부 동기화 없이 mutable scratch space를 직접 전달하는 기능이 요구됨
- 스트림이나 rope 같은 비연속 haystack에서 정규식을 실행하는 기능도 요청됨
- 별도 버전 crate로 내부를 공개하면 일반 목적
regexAPI를 복잡하게 만들지 않고, 전문가용 API를 더 빠른 breaking change 주기로 실험할 수 있음
완전 컴파일 DFA가 만든 추상화 경계
regex-automata의 초기 동기는 완전 컴파일 DFA를 만들고 직렬화한 뒤, zero-copy 역직렬화로 검색하는 최소 런타임을 제공하는 것이었음- 초기
regex-automata는bstr의 Unicode 알고리듬 구현용 DFA를 만드는 데 쓰임 - DFA를 만들다 보니
regexcrate와 유사한 NFA 자료구조와 컴파일러가 필요했고, 이 코드가 복잡해지면서 공유 필요성이 커짐 - 처음에는
regex-nfa같은 별도 crate를 고려했지만, determinization 과정 등 더 많은 코드가regex와regex-automata사이에서 공유될 수 있었음 - 추상화 경계는 “NFA”보다 정규식 엔진에 가까웠고, 최종적으로
regex-automata는 여러 엔진의 집합으로 재정의됨 - 장기 계획은 모든 정규식 엔진을
regex-automata에 두고regexcrate를 그 위의 얇은 래퍼로 만드는 것이었음
regex-cli로 내부 구조 확인
regex-cli는regexcrate 저장소에서 관리되는 프로그램으로,regex-syntax,regex-automata,regex의 여러 API에 명령줄 접근을 제공함- 설치는 다음 명령으로 가능함
cargo install regex-cli
regex-cli debug는 AST, HIR, 리터럴, Thompson NFA, one-pass DFA, dense DFA, sparse DFA 등을 출력할 수 있음- Unicode가 켜진
.정규식은 UTF-8 scalar value를 처리하기 위해 훨씬 복잡한 Thompson NFA를 만들고, Unicode가 꺼진(?-u:.)는 더 단순한 NFA를 만듦 regex-cli find는 임시 검색을 실행할 수 있고, meta engine으로 다중 패턴 검색과 캡처 그룹 출력도 가능함
정규식 처리 데이터 흐름
Regex::new에 전달된 패턴 문자열은 먼저Ast로 파싱됨Ast는Hir로 변환됨Hir는Ast보다 세부 정보가 적고, Unicode case folding과 Unicode character class 참조가 변환 중 확장됨
Hir에서는 두 가지가 만들어짐- 검색 최적화에 쓰이는 리터럴 시퀀스
- Thompson
NFA
- NFA는 여러 엔진을 만드는 기반이 됨
PikeVM: 파싱 가능한 모든 정규식을 처리하고 캡처 그룹 오프셋을 보고함BoundedBacktracker: bounded backtracking으로 캡처 그룹 오프셋을 보고함- one-pass DFA: 제한된 정규식에서 캡처 그룹 오프셋을 빠르게 보고함
- dense DFA: 매우 빠르지만 전체 매치 시작·끝만 보고하며, 구성은 최악
O(2^m)시간·공간이 필요함 - lazy DFA: 검색 중 NFA에서 DFA를 만들며, 보통 full DFA만큼 빠르고 full DFA의 지수적 구성 비용을 피함
- 이 엔진들과
Prefilter는 하나의 meta regex engine으로 조합되고,regexcrate는 이 meta engine의 얇은 래퍼임
리터럴 최적화
- 리터럴 추출은
regex내부의 핵심 최적화임- 예를 들어
(foo|bar|quux)(\s+\w+)의 모든 매치는foo,bar,quux중 하나로 시작함
- 예를 들어
- 리터럴이 중요한 이유는 단일 또는 소수의 문자열 검색 알고리듬이 매우 빠르기 때문임
- 벡터 명령으로 haystack의 여러 바이트를 한 번에 처리할 수 있음
- 일반 정규식 매칭 알고리듬은 같은 방식으로 일관되게 가속하기 어려움
- 잘 최적화된 substring 검색은 일반 정규식 엔진보다 최소 한 자릿수 이상 빠른 경우가 자주 있었음
- 리터럴 추출은 휴리스틱임
- 후보 매치의 false positive rate를 줄여야 함
- prefilter가 전체 지연 시간에 미치는 영향도 낮춰야 함
- 두 조건 모두 haystack에 의존하지만, 검색 전에 haystack을 분석하면 전체 검색 시간이 나빠질 수 있음
- 리터럴 시퀀스는 집합이 아니라 순서가 있는 시퀀스임
regexcrate는 Perl-like leftmost-first 의미론을 따르므로|는 교환법칙이 성립하지 않음sam|samwise는sam만 추출될 수 있지만,samwise|sam은 둘 다 고려됨
- 단일 문자열 검색에는
memchrcrate의memmem모듈이 쓰임- 주 알고리듬은 최악
O(n)시간과 상수 공간을 갖는 Two-Way임 - 짧은 needle과 haystack에는 Rabin-Karp가 쓰임
x86_64에서는 generic SIMD 변형이 활용됨
- 주 알고리듬은 최악
- 다중 문자열 검색에는 Hyperscan에서 포팅한 Teddy가 주요 알고리듬으로 쓰이며, 일부 경우 Aho-Corasick도 사용됨
Thompson NFA와 최적화
regexcrate 내부의 중심 자료구조는 Thompson NFA임- Thompson’s construction은 정규식의 구조화 표현에서 NFA를
O(m)시간에 만들며,m은 counted repetition 확장 후 정규식 크기에 비례함 - NFA는 직접 정규식 엔진으로 사용할 수 있고, DFA 같은 다른 타입으로 변환해 다른 엔진의 기반으로도 사용됨
- 새 NFA 컴파일러의 주요 최적화는 epsilon transition을 줄이는 데 집중함
- Thompson NFA는 구성 시간이 좋지만 epsilon transition을 많이 사용함
- epsilon closure 계산은 검색이나 DFA 구성 중 반복 비용을 만들 수 있음
sparsestate 최적화는 여러 범위 전이를 하나의 상태로 표현해 기존의 여러Splitinstruction을 줄임[A-Za-z0-9]같은 정규식에서 불필요한 epsilon transition을 제거함- 현재 표현에서는 indirection 때문에 cache 영향과 heap memory 증가 가능성이 있음
- 최소 UTF-8 오토마타 최적화는 큰 Unicode class에서 NFA 크기를 크게 줄임
- 예전 byte-oriented NFA에서
\w는 3,564 states를 만들 수 있었음 - 새 컴파일러는 Daciuk’s algorithm을 사용해 훨씬 적은 상태와 zero epsilon transition을 갖는 구조를 만듦
- reverse NFA의 shrink는 compile time 비용 때문에 기본 비활성화됨
- 예전 byte-oriented NFA에서
- literal trie 최적화는
zap|z|zapper,abc|xyz같은 리터럴 alternation에서 공통 구조를 trie로 컴파일해 epsilon transition을 줄임- leftmost-first 의미론을 보존하기 위해 match가 나타나는 지점마다 transition chunk를 나눔
- 향후 NFA 작업으로 Glushkov NFA와 단일 contiguous allocation 저장이 검토됨
- Glushkov NFA는 epsilon transition이 없지만 compile time complexity가 더 나쁨
- contiguous allocation은 cache friendliness와 zero-copy 직렬화·역직렬화 가능성을 줄 수 있지만 코드 복잡성과
unsafe사용 가능성이 있음
개별 정규식 엔진
regex-automata의 엔진들은 비슷한 API를 공유함Input: haystack, 검색 범위, anchored 여부, early stop 여부를 설정함Match: 매치된 byte span과PatternID를 담음MatchError: 검색 결과를 결정할 수 없는 오류를 나타냄
-
PikeVM
regex-syntax가 파싱할 수 있는 전체 기능을 지원하고, 어떤 길이의 haystack에서도 동작함- 캡처 그룹 위치를 추적하고, active state들을 lock-step으로 계산해 최악
O(m * n)시간을 보장함 - 많은 상태와 캡처 그룹 위치를 추적해야 하므로 성능이 주요 약점임
regex-automata엔진 중 검색 중 오류를 반환하지 않는 고유한 특성이 있음
-
BoundedBacktracker
- Thompson NFA 위에서 backtracking을 쓰지만 이미 수행한 작업을 다시 추적하지 않도록 추가 상태를 사용함
- 최악
O(m * n)시간을 보장하지만O(m * n)공간을 사용함 - 대략적인 실험에서 보통
PikeVM보다 약 2배 빠름 - haystack 길이와 정규식 크기가 설정된 visited capacity를 넘으면 실패할 수 있음
-
one-pass DFA
- 제한된 one-pass NFA에서 캡처 그룹 오프셋을 매우 빠르게 보고함
- 캡처 그룹을 보고할 수 있는 가장 빠른 방식으로 꼽힘
- anchored search만 지원하며, 많은 정규식은 one-pass가 아님
- Unicode 모드에서는 byte level transition overlap 때문에 one-pass가 아니던 정규식이 Unicode를 끄면 one-pass가 될 수 있음
-
fully compiled DFA
- forward DFA와 reverse DFA 두 개로 구성되어 전체 매치 끝과 시작을 찾음
- 구성은 최악
O(2^m)시간·공간이고, dense DFA는 메모리를 많이 씀 - full DFA 엔진은
regexcrate에서 기본 비활성화되어 있으며perf-dfa-fullfeature로 opt-in해야 함 - mutable
Cache없이 동작하고, raw bytes로 직렬화해core만 있는 환경에서도 검색 런타임을 사용할 수 있음
-
hybrid NFA/DFA, lazy DFA
- full DFA와 유사하지만 transition table을 검색 중에 만듦
- cache에 이미 계산된 transition이 있으면 재사용하고, 없으면 NFA powerset construction으로 해당 transition만 계산함
- 최악 검색 시간은
O(m * n), 공간은 구성 시 정한 cache capacity로 제한됨 - 일반적인 경우 대부분의 state와 transition이 캐시되어 평균
O(n)처럼 동작하며, 실제로 많은 정규식에서 full DFA와 비슷한 검색 성능을 보임 - cache가 반복적으로 가득 차 비효율적이면 오류를 반환하고, meta engine에서는 보통 다른 엔진으로 재시도함
meta regex engine의 역할
meta regex engine은 여러 엔진을 하나로 묶고, 호출자에게 infallible API를 제공하려고 함- 호출자는 각 검색마다
Cache를 직접 만들고 전달하지 않아도 됨- meta engine은 내부 thread-safe cache pool을 관리함
- 동기화 비용을 피하려는 경우 명시적으로
Cache를 전달하는 lower-level API도 제공함
regex::Regex,regex::RegexSet,regex::bytes::Regex,regex::bytes::RegexSet은 모두 meta engine의 얇은 래퍼임- meta engine의 내부 전략은 대략 다음과 같음
- 정규식 엔진 없이 단일·다중 substring 검색만으로 처리할 수 있으면 NFA 구성도 피함
- 가능하면 prefix 리터럴 시퀀스를 추출해
Prefilter로 사용함 - prefix가 적절하지 않으면 reverse anchored, reverse suffix, reverse inner 최적화를 시도함
- 그 외에는
PikeVM, bounded backtracker, one-pass DFA, lazy DFA, full DFA를 포함한 core strategy로 fallback함
- 전체 전략은 두 문장으로 압축됨
- 가능한 한 리터럴을 검색함
- 가능한 한
PikeVM사용을 피함
- reverse suffix와 reverse inner 최적화는 잘못하면 haystack 크기에 대해 최악
O(m * n^2)가 될 수 있음- meta engine은 reverse scan이 이전 suffix match 끝을 넘어가려는 상황을 감지하고 core strategy로 fallback해 시간 복잡도 보장을 유지함
RE2와의 차이
regexcrate와 RE2는 유사점이 많음- RE2에도 PikeVM에 해당하는 NFA, bitstate backtracker, one-pass NFA, lazy DFA, 여러 엔진을 조합하는 구조가 있음
- 위 엔진 중 RE2에 없는 것은 fully compiled DFA임
- 주요 차이는 다음과 같음
- RE2는 leftmost-first 외에 POSIX식 leftmost-longest 의미론을 옵션으로 지원함
- RE2는 Unicode 지원이 더 제한적이며,
\w,\s,\d,\b를 Unicode 정의로 쓰는 옵션이 없음 - RE2는 character class set operation 중 union 외 지원이 제한적임
- RE2의 PikeVM은 더 메모리 효율적일 가능성이 있음
- RE2의 리터럴 최적화는 제한적이며,
regexcrate가 더 많은 리터럴 최적화를 수행함 - RE2 lazy DFA는 여러 thread가 같은 transition cache를 공유해 동기화가 필요하지만,
regexcrate는 thread별 별도 cache를 요구해 더 많은 메모리를 씀 regexcrate는regex-syntax와regex-automata를 별도 버전 라이브러리로 공개하지만, RE2는 이를 지원하지 않음regex-automata는 모든 엔진에서 first-class multi-pattern regex를 지원하고, 각 매칭 패턴의 match 및 capture group offset도 보고할 수 있음
테스트 전략과 벤치마크
- 새 테스트 전략은 내부 엔진을 독립적인 first-class API로 만들고 각 엔진을 직접 테스트하는 방식임
- 모든 정규식 테스트는 TOML 파일로 지정됨
regex-testcrate가 TOML 테스트를 읽어 구조화된 표현으로 변환함- 각 엔진 구성마다 하나의 Rust unit test를 두고, 해당 엔진에 적용 가능한 모든 TOML 테스트를 실행함
- Rust unit test framework가 확장 가능하지 않아, 특정 테스트 필터링을 위한 자체 environment variable 인프라가 추가됨
regex-automata에만 450개가 넘는 documentation test가 있음regex 1.9준비 과정에서 fuzz testing target도 많이 추가되었고, Addison Crump의 도움으로 몇몇 버그를 찾음- 벤치마크는
rebar라는 regex barometer로 공개됨rebar는regexcrate뿐 아니라 여러 정규식 엔진을 벤치마크함- 242개 벤치마크에서
regex 1.9는 검색 시간 기준regex 1.7.3보다 평균 1.5배 빠름 - 정규식을 빌드하는 시간은 다소 회귀함
1.8은 일부 전환 작업이 포함된 transition release라 비교 기준으로1.7이 사용됨
비용과 경량 대안
- 재작성에는 지난 몇 년간 작성자의 자유 시간 대부분이 쓰였고, ripgrep 같은 프로젝트는 한동안 릴리스되지 못함
- 재사용 가능한 공개 추상화는 내부 전용 추상화보다 코드가 더 많아지는 경향이 있음
- 결과적으로 바이너리 크기와 컴파일 시간이 증가함
- 내부 엔진 API가 별도 버전으로 공개되면서, API를 깨려면
regex-automata의 적절한 breaking change release가 필요함 - 비용 완화를 위해 두 가지 조치가 적용됨
- fully compiled DFA 엔진은 기본 비활성화하고 opt-in feature로 둠
regex-lite를 새 crate로 공개함
regex-lite는regexcrate의 거의 drop-in replacement를 목표로 하지만, 바이너리 크기와 컴파일 시간 최적화에 집중함- Unicode와 성능 측면의 기능을 포기함
O(m * n)시간 복잡도 보장은 유지함- 의존성이 0개이며, 자체 정규식 파서를 포함해
regexcrate와 코드를 공유하지 않음
regex-lite는 아직 실험적 완화책이지만,regexcrate의 feature로 최적화와 Unicode 기능을 꺼도regex-lite의 바이너리 크기와 컴파일 시간에는 가까워지기 어렵다는 점을 보여줌