Skip to content

Commit 64f5c88

Browse files
committed
Indexing
Support re2match using btree to find prefix Support GIN indexing with @~ operator
1 parent 1399d21 commit 64f5c88

22 files changed

Lines changed: 1814 additions & 56 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ jobs:
2626
runs-on: ubuntu-latest
2727
container: pgxn/pgxn-tools
2828
steps:
29-
- run: pg-start ${{ matrix.pg }} libre2-dev
29+
- run: pg-start ${{ matrix.pg }} libre2-dev libicu-dev
3030
- uses: actions/checkout@v7
3131
- run: pg-build-test

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
- name: Check out the repo
1212
uses: actions/checkout@v7
1313
- name: Start Postgres
14-
run: pg-start 19 libre2-dev
14+
run: pg-start 19 libre2-dev libicu-dev
1515
- name: Bundle the Release
1616
id: bundle
1717
run: pgxn-bundle

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ All notable changes to this project will be documented in this file. It uses the
1414
* Added `re2_version()`, which returns the full semantic version. This is
1515
the same value visible in `pg_get_loaded_modules()`, but available in
1616
Postgres versions prior to 18, and without having to load re2 first.
17+
* Index support.
18+
* `WHERE re2match(col, '^order_2025')` a pattern starting with `^` and a
19+
fixed prefix now reads only matching range of an existing b-tree index.
20+
See [Index Support](doc/re2.md#index-support).
21+
* Added `@~` match operator (same as `re2match`) and `gin_re2_ops`
22+
operator class. `CREATE INDEX ... USING gin (col gin_re2_ops)` lets
23+
`WHERE col @~ pattern` only visit rows containing required trigrams.
24+
* Row-count estimates for `re2match` and `@~` filters with a constant
25+
pattern now come from testing the pattern against column statistics
26+
instead of a fixed guess, so the planner picks better plans around
27+
such filters.
28+
29+
### 📔 Notes
30+
31+
* Run `ALTER EXTENSION re2 UPDATE TO '0.4'` to expose the new operator and
32+
operator class on existing databases.
1733

1834
[v0.4.0]: https://github.com/clickhouse/pg_re2/compare/v0.3.0...v0.4.0
1935

Makefile

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ DISTVERSION = $(shell grep -m 1 '^[[:space:]]\{2\}"version":' META.json | \
77

88
DATA = $(wildcard sql/$(EXTENSION)--*.sql)
99
MODULE_big = $(EXTENSION)
10-
OBJS = src/pg_re2.o src/re2_cache.o src/re2_wrapper.o
10+
OBJS = src/pg_re2.o src/pg_re2_index.o src/re2_cache.o src/re2_wrapper.o
1111

1212
PG_CONFIG ?= pg_config
1313
PG_CXXFLAGS = -std=c++17
@@ -28,17 +28,17 @@ $(OBJS): src/version.h
2828
src/version.h: src/version.h.in
2929
sed -e 's,__VERSION__,$(DISTVERSION),g' $< > $@
3030

31-
.PHONY: format
32-
format: src/*.c src/*.h src/*.cpp
33-
clang-format -i $^
31+
CLANG_FORMAT ?= clang-format
3432

35-
.PHONY: lint
36-
lint: src/*.c src/*.h src/*.cpp
37-
clang-format --dry-run --Werror $^
33+
.PHONY: format format22
34+
format22: CLANG_FORMAT = clang-format-22
35+
format format22: src/*.c src/*.h src/*.cpp
36+
$(CLANG_FORMAT) -i $^
3837

39-
.PHONY: lint22
40-
lint22: src/*.c src/*.h src/*.cpp
41-
clang-format-22 --dry-run --Werror $^
38+
.PHONY: lint lint22
39+
lint22: CLANG_FORMAT = clang-format-22
40+
lint lint22: src/*.c src/*.h src/*.cpp
41+
$(CLANG_FORMAT) --dry-run --Werror $^
4242

4343
.PHONY: bench
4444
bench:

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ make CPPFLAGS=-I/opt/homebrew/include \
7474

7575
If you encounter an error such as:
7676

77+
```
78+
utils/pg_locale.h:24:10: fatal error: 'unicode/ucol.h' file not found
79+
```
80+
81+
Install the ICU development headers Postgres was built against, e.g.
82+
`libicu-dev` on Debian and Ubuntu.
83+
84+
If you encounter an error such as:
85+
7786
```
7887
ERROR: must be owner of database regression
7988
```

benchmark/README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,61 @@ Data is 10000 rows of:
2222
- `logline` ~200 chars
2323
- `longtext` ~2000 chars (400 words)
2424

25+
Index scans
26+
-----------
27+
28+
`re2` also speeds up `re2match` through two index mechanisms (see
29+
[Index Support]). This compares each against the equivalent PostgreSQL
30+
index scan over a separate 100000-row table.
31+
32+
| Mechanism | re2 | postgres |
33+
| ------------------- | ---------------------------- | ---------------------------- |
34+
| b-tree prefix range | `re2match(col, '^lit')` | `col ~ '^lit'` |
35+
| GIN trigram | `col @~ pat` (`gin_re2_ops`) | `col ~ pat` (`gin_trgm_ops`) |
36+
37+
![Index benchmark](graph_index.png)
38+
39+
| Category | Pattern | rows | re2 | postgres | re2 vs postgres |
40+
| -------- | ----------------------------- | ----- | ------- | -------- | --------------- |
41+
| btree | `^user5` | 11111 | 1.8 ms | 3.5 ms | 1.9x faster |
42+
| btree | `^user12[0-9]` | 1110 | 0.21 ms | 0.43 ms | 2.0x faster |
43+
| gin | `error_code=123` | 100 | 3.3 ms | 3.6 ms | 1.1x faster |
44+
| gin | `error_code=(100\|200\|300)` | 301 | 3.5 ms | 4.9 ms | 1.4x faster |
45+
46+
The two GIN opclasses extract keys differently. `pg_trgm` builds trigrams from
47+
alphanumeric words only (never spanning `_`, `=`, …) and prunes extracted
48+
trigrams under a fixed penalty budget tuned for natural-language text;
49+
`gin_re2_ops` keeps every byte trigram of each literal atom RE2's `FilteredRE2`
50+
requires. On punctuated machine-text patterns (e.g. `error_code=42[0-9]` over
51+
loglines where `error_code=` appears in every row) pruning can leave `pg_trgm`
52+
with only ubiquitous trigrams, degenerating to a full-index scan while
53+
`gin_re2_ops` stays selective, an order of magnitude faster. On plain-word
54+
patterns both extract similar keys and `pg_trgm`'s cheaper consistent check
55+
can win (see `error_code=123` above).
56+
2557
Methodology
2658
-----------
2759

2860
- JIT and query parallelism disabled to compare single-thread engine throughput reliably
2961
- `gen_graph.py` takes the median time per (pattern, engine) across all iterations
62+
- Index scans use a `text_pattern_ops` b-tree and two GIN indexes on one table;
63+
`enable_seqscan` is off there so both engines are measured on their index
3064

3165
Running
3266
-------
3367

34-
Requires `re2` extension installed (see [README]) and PostgreSQL 15+ for builtin comparisons
68+
Requires `re2` (see [README]) and PostgreSQL 15+ for builtin comparisons.
69+
The index-scan section additionally needs the `pg_trgm` contrib extension;
70+
`setup.sql` creates it.
3571

3672
Connection uses libpq environment variables; override the `psql` binary with
3773
`PSQL`:
3874

3975
``` sh
4076
PGDATABASE=mydb ./run_bench.sh # 5 iterations (default)
4177
PGDATABASE=mydb ./run_bench.sh 10 # 10 iterations
42-
./gen_graph.py # regenerate graph.png from results.csv
78+
./gen_graph.py # regenerate graph.png & graph_index.png
4379
```
4480

4581
[README]: ../README.md
82+
[Index Support]: ../doc/re2.md#index-support

benchmark/bench.sql

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,60 @@ SELECT 'count_matches', 'words_longtext', 'pg_builtin',
262262
(SELECT sum(regexp_count(longtext, '\w+')) FROM bench_data WHERE id <= 2500),
263263
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
264264
FROM (SELECT clock_timestamp() AS ts) t;
265+
266+
-- ============================================================
267+
-- 7. INDEX SCAN: re2 index support vs postgres index scan
268+
-- idx_btree re2match(col,'^lit') planner support -> text_pattern_ops range,
269+
-- vs postgres' own '~' prefix support on the same index
270+
-- idx_gin col @~ pat (gin_re2_ops, RE2 FilteredRE2 atoms)
271+
-- vs col ~ pat (pg_trgm gin_trgm_ops)
272+
-- enable_seqscan off so both engines are measured on their index, not a scan
273+
-- ============================================================
274+
SET enable_seqscan = off;
275+
276+
-- 7a. b-tree literal prefix (~11k rows in range, recheck cost visible)
277+
SELECT 'idx_btree', 'prefix_literal', 're2',
278+
(SELECT count(*) FROM bench_index_data WHERE re2match(email, '^user5')),
279+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
280+
FROM (SELECT clock_timestamp() AS ts) t;
281+
282+
SELECT 'idx_btree', 'prefix_literal', 'pg_builtin',
283+
(SELECT count(*) FROM bench_index_data WHERE email ~ '^user5'),
284+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
285+
FROM (SELECT clock_timestamp() AS ts) t;
286+
287+
-- 7b. b-tree literal prefix + bounded char-class tail (prefix still extractable)
288+
SELECT 'idx_btree', 'prefix_charclass', 're2',
289+
(SELECT count(*) FROM bench_index_data WHERE re2match(email, '^user12[0-9]')),
290+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
291+
FROM (SELECT clock_timestamp() AS ts) t;
292+
293+
SELECT 'idx_btree', 'prefix_charclass', 'pg_builtin',
294+
(SELECT count(*) FROM bench_index_data WHERE email ~ '^user12[0-9]'),
295+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
296+
FROM (SELECT clock_timestamp() AS ts) t;
297+
298+
-- 7c. GIN required literal atom (re2 keeps a tight candidate set; pg_trgm wins
299+
-- here because its per-candidate consistent check is far cheaper)
300+
SELECT 'idx_gin', 'literal', 're2',
301+
(SELECT count(*) FROM bench_index_data WHERE logline @~ 'error_code=123'),
302+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
303+
FROM (SELECT clock_timestamp() AS ts) t;
304+
305+
SELECT 'idx_gin', 'literal', 'pg_builtin',
306+
(SELECT count(*) FROM bench_index_data WHERE logline ~ 'error_code=123'),
307+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
308+
FROM (SELECT clock_timestamp() AS ts) t;
309+
310+
-- 7d. GIN alternation (atoms on each branch)
311+
SELECT 'idx_gin', 'alternation', 're2',
312+
(SELECT count(*) FROM bench_index_data WHERE logline @~ 'error_code=(100|200|300)'),
313+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
314+
FROM (SELECT clock_timestamp() AS ts) t;
315+
316+
SELECT 'idx_gin', 'alternation', 'pg_builtin',
317+
(SELECT count(*) FROM bench_index_data WHERE logline ~ 'error_code=(100|200|300)'),
318+
(extract(epoch FROM clock_timestamp() - ts) * 1000)::numeric(12,2)
319+
FROM (SELECT clock_timestamp() AS ts) t;
320+
321+
RESET enable_seqscan;

benchmark/gen_graph.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,10 @@ def chunk(kind, data):
184184
f.write(png)
185185

186186

187-
def plot_speedup(tests, filename='graph.png'):
188-
"""Render horizontal speedup bars"""
187+
def plot_speedup(tests, filename='graph.png',
188+
title='re2 speedup over postgresql builtin regex',
189+
axis_cap=None):
190+
"""Render horizontal speedup bars; axis_cap clips outliers to given max"""
189191
rows = _rows_by_speedup(tests)
190192
path = os.path.join(OUT_DIR, filename)
191193

@@ -201,7 +203,10 @@ def plot_speedup(tests, filename='graph.png'):
201203
display_rows = list(reversed(rows))
202204
speedups = [r[3] for r in rows]
203205
max_speedup = max(speedups)
204-
ticks = _tick_values(max_speedup * 1.05)
206+
target = max_speedup * 1.05
207+
if axis_cap:
208+
target = min(target, axis_cap)
209+
ticks = _tick_values(target)
205210
axis_max = max(ticks)
206211

207212
label_width = max(_text_width(label, 2) for label, _, _, _ in rows)
@@ -224,7 +229,7 @@ def plot_speedup(tests, filename='graph.png'):
224229
height,
225230
24,
226231
24,
227-
're2 speedup over postgresql builtin regex',
232+
title,
228233
FG,
229234
3,
230235
)
@@ -251,8 +256,10 @@ def plot_speedup(tests, filename='graph.png'):
251256

252257
value = f'{speedup:.1f}x'
253258
value_width = _text_width(value, 2)
254-
value_color = BG if value_width + 16 <= bar_width else FG
255-
_draw_text(pixels, width, height, left + 8, y + 2, value, value_color, 2)
259+
if value_width + 16 <= bar_width:
260+
_draw_text(pixels, width, height, left + 8, y + 2, value, BG, 2)
261+
else:
262+
_draw_text(pixels, width, height, left + bar_width + 6, y + 2, value, FG, 2)
256263

257264
if 1 <= axis_max:
258265
x = left + plot_width / axis_max
@@ -276,8 +283,21 @@ def plot_speedup(tests, filename='graph.png'):
276283
print(f"Saved {path}")
277284

278285

286+
def _split_index(tests):
287+
"""Partition tests into (throughput, index) by category prefix"""
288+
throughput = {}
289+
index = {}
290+
for key, engines in tests.items():
291+
(index if key[0].startswith('idx_') else throughput)[key] = engines
292+
return throughput, index
293+
294+
279295
if __name__ == '__main__':
280296
data = load_results()
281297
tests = build_comparison_data(data)
282-
plot_speedup(tests)
298+
throughput, index = _split_index(tests)
299+
plot_speedup(throughput, 'graph.png')
300+
if index:
301+
plot_speedup(index, 'graph_index.png',
302+
're2 index scan speedup over postgresql', axis_cap=20)
283303
print("Done.")

benchmark/graph_index.png

3.82 KB
Loading

benchmark/run_bench.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ for i in $(seq 1 "$ITERATIONS"); do
1818
echo "=== Iteration $i/$ITERATIONS ===" >&2
1919
"$PSQL" -X -q -f "$SCRIPT_DIR/bench.sql" | while IFS= read -r line; do
2020
case "$line" in
21-
match,*|extract,*|extract_all,*|extract_all_unnest,*|replace_one,*|replace_all,*|count_matches,*)
21+
match,*|extract,*|extract_all,*|extract_all_unnest,*|replace_one,*|replace_all,*|count_matches,*|idx_btree,*|idx_gin,*)
2222
echo "${line},${i}" ;;
2323
esac
2424
done >> "$OUTFILE"

0 commit comments

Comments
 (0)