Date: 2026-08-24 (Asia/Shanghai)
Status: local engineering benchmark, not a cross-platform release claim
The matcher core is fast. The dominant cost depends on the execution shape:
- the original Go program remains the best path for a specialized, one-shot, streaming file transformation;
- a single PostgreSQL backend is about 1.8x slower than that specialized Go path on the historical 10.3-million-row short-text replacement workload;
- PostgreSQL parallel execution reduces the in-server replacement computation from 2.007 seconds to 0.510 seconds with four workers and 0.291 seconds with eight workers;
- those parallel numbers exclude client transfer, durable writes, and WAL;
- on 1 KiB and 64 KiB rows, PostgreSQL
containssustains about 0.91 GiB/s in one backend, or about 94% of the matching standalone root-miss core result; - on roughly 33-byte rows, PostgreSQL per-row executor and function-call cost reduces one-backend throughput to about 0.52 GiB/s;
- the C extension's cached runtime for the 1,114-pattern real dictionary is about 227 KiB, versus about 3.56 MiB of retained automaton heap in the Go reference;
- exact full-corpus replacement output is byte-for-byte identical between Go and PostgreSQL when SQL row order is made explicit.
The practical conclusion is that Go is the faster streaming batch tool, while the extension is a strong in-database rule engine, especially when candidate rows are already in PostgreSQL, rows are not extremely short, or parallel query is available.
| Component | Revision |
|---|---|
Original Go reference (origin/master) |
4fe39443915cd1879ef07474f10f87dc0694a0f4 |
PostgreSQL extension (main) |
0d8a7c422639bf20c2550a976d845afd40389728 |
| Extension version | 0.1.1 |
The installed acdat.dylib was byte-identical to the artifact built from the
tested main revision.
| Item | Value |
|---|---|
| System | Apple M5 Max, arm64 |
| CPU | 18 physical cores |
| Memory | 128 GiB |
| OS | macOS 26.5.2, build 25F84 |
| Go | go1.27.0 darwin/arm64 |
| C compiler | Apple clang 21.0.0 |
| PostgreSQL | 18.6 (Homebrew) |
No thermal or performance warning was reported by macOS. The host was not otherwise isolated: it was a multi-user development machine with a load average around 3-5 during the measurement window. Medians are used to reduce short-lived scheduling noise.
The isolated PostgreSQL instance used:
shared_buffers = 2GB;jit = off;autovacuum = offduring the benchmark;fsync = off,synchronous_commit = off, andfull_page_writes = offfor setup speed;- explicit
parallel_workersrelation settings and zero parallel setup/tuple cost when measuring fixed four- and eight-worker plans.
The scan queries were read-only, so disabled durability does not change their CPU cost. It does mean that compile-and-store timings are not durable-commit timings.
- All primary scan results are warm-cache measurements.
- Go stage measurements use five runs and report medians.
- PostgreSQL real-corpus measurements use three runs and report medians.
- The standalone C matrix uses three runs per cell and reports medians.
- PostgreSQL 100K compile and short-row measurements use five runs.
four workersandeight workersmean workers launched by aGather; the leader can also participate, so the worst-case machine-memory budget is five or nine process-local copies.- PostgreSQL
contains,matches, andreplacetimings return one aggregate row. They include table scanning, executor overhead, function calls, machine materialization once per participating process, and result construction, but not transfer of every transformed row to a client. - The Go result writes to the filesystem page cache and does not call
fsync. - Throughput for the historical corpus divides its 306.928 MiB source size by elapsed time.
These distinctions are essential. A 0.291-second parallel replace aggregate
is not a claim that PostgreSQL can durably export or update 322 MB in 0.291
seconds.
The original repository supplies the contest dictionary and input corpus.
| Item | Value |
|---|---|
| Dictionary rules | 1,114 |
| Dictionary bytes | 27,659 |
| Input rows | 10,318,163 |
| Input bytes | 321,837,358 (306.928 MiB) |
| Result bytes | 322,091,448 (307.170 MiB) |
| Maximum pattern | 40 UTF-8 bytes / 14 characters |
| Rows containing at least one pattern | 581,162 (5.632%) |
| Overlapping matches | 601,838 |
Content fingerprints:
| Artifact | SHA-256 or MD5 |
|---|---|
dict.txt SHA-256 |
73f1003f290206cc15bf5aba8d6024835ec9d320dc3f5d4deb1f913216402249 |
video_title.txt SHA-256 |
8665b95ca1b435a107285ac947667e8010b7efee8220b73e5acd6795da0be7fc |
| Expected replacement result MD5 | 5d76461b53079d20c08eb0b33c46b7cd |
The four large repository data shards and the dictionary archive were also verified against their Git blob IDs before extraction.
Correctness passed at the corpus, SQL, and extension-test levels:
- The Go reference produced 10,318,163 rows and 322,091,448 bytes with the historical expected MD5.
- PostgreSQL compiled the same 1,114 patterns, IDs, priorities, and literal replacements.
- PostgreSQL produced the same row count, byte count, and MD5 under
leftmost_longestreplacement. make core-testpassed.make installcheckpassed bothacdat_basicandacdat_errors.make cache-smoke-testpassed the 100K-machine cache invariant.
The source file uses CRLF line endings. The PostgreSQL loader preserved the
carriage return inside each text value, and a source line number was retained.
The final verification query used ORDER BY line_no. Without an explicit
order, a heap scan is free to return rows in physical order; an early MD5
mismatch was traced entirely to row order, not replacement content.
The original Go implementation is specialized for this workload: rune-based matching, three hard-coded replacement categories, a fixed small match buffer, and buffered streaming I/O.
An instrumented harness mirroring lib.Run measured the following medians:
| Stage | Median | Share of instrumented full run | Notes |
|---|---|---|---|
| Parse dictionary and build automaton | 1.401 ms | 0.12% | 1,114 rules |
| Read, find, and replace; discard output | 1.070 s | 92.44% | Includes input read |
| Buffered output-write increment | about 82.6 ms | 7.14% | Difference between write and discard medians |
| Harness/GC/close residual | about 3.5 ms | 0.30% | Measurement overhead |
| Instrumented full run | 1.157 s | 100% | Cached I/O, no fsync |
| Streaming MD5 verification, if requested | 0.388 s | separate | Reads the 322 MB result again |
A pure cached copy of the 321.8 MB input took 63.8 ms. This supports the conclusion that matching and replacement, not cached file movement, dominate the Go runtime.
Five independent executions of the uninstrumented one-shot Go binary had a median of 1.11 seconds:
- 9.30 million rows/s;
- 276.5 MiB/s;
- about 10.5-11.4 MiB process peak RSS;
- exact expected output MD5.
The repository's built-in Benchmark(10) should not be used as the primary
memory number. It runs with GOGC=off, rebuilds the automaton repeatedly in one
process, and its correctness check calls ioutil.ReadFile on the complete
322 MB output. That benchmark reached roughly 384 MiB RSS even though a normal
one-shot streaming execution stayed near 11 MiB.
| Stage | Median or size |
|---|---|
| Aggregate compile, returning machine size | 1.162 ms |
| Compile plus CTAS/TOAST storage | 3.095 ms |
| Stored artifact | 275,618 bytes (269.2 KiB) |
| Materialized cached runtime | 232,630 bytes (227.2 KiB) |
| States / DAT slots | 10,840 / 10,984 |
| Replacement payload | 11,182 bytes |
Dictionary build is negligible relative to scanning 10.3 million rows.
| Workload | One backend | Four workers | Eight workers | Result |
|---|---|---|---|---|
| Raw table scan aggregate | 0.244 s | 0.069 s | 0.045 s | 311,519,195 text bytes excluding row LF |
contains |
1.522 s / 201.7 MiB/s | 0.398 s / 771.7 MiB/s | 0.228 s / 1.31 GiB/s | 581,162 hit rows |
replace(leftmost_longest) |
2.007 s / 153.0 MiB/s | 0.510 s / 602.1 MiB/s | 0.291 s / 1.03 GiB/s | 322,091,448 output bytes including LF |
matches(all_overlapping) |
3.678 s / 83.4 MiB/s | 0.816 s / 376.3 MiB/s | 0.476 s / 645.1 MiB/s | 601,838 matches |
Parallel speedups relative to one backend were:
| Workload | Four workers | Eight workers |
|---|---|---|
contains |
3.83x | 6.67x |
replace |
3.94x | 6.89x |
matches |
4.51x | 7.73x |
Subtracting the 0.244-second raw scan median gives an approximate view of where single-backend time goes. This is a query-level subtraction, not a direct C profiler measurement.
| Workload | Total | Raw scan share | Function/executor/result increment |
|---|---|---|---|
contains |
1.522 s | 0.244 s (16.0%) | 1.278 s (84.0%) |
replace |
2.007 s | 0.244 s (12.1%) | 1.763 s (87.9%) |
matches |
3.678 s | 0.244 s (6.6%) | 3.435 s (93.4%) |
For this short-row corpus, table access is not the bottleneck. Per-row function entry, matching, replacement construction, match tuple construction, and SRF execution dominate.
| Comparison | Go reference | PostgreSQL extension | Interpretation |
|---|---|---|---|
| Real-dictionary build | 1.401 ms | 1.162 ms, or 3.095 ms stored | Both are negligible |
| Retained machine/automaton heap | 3.56 MiB | 227 KiB cached runtime | C runtime is about 16x smaller |
| Specialized one-shot replacement | 1.11 s | 2.007 s, one-backend server computation | PostgreSQL is about 1.81x slower on short rows |
| In-server replacement, four workers | n/a | 0.510 s | About 2.18x faster than the one-shot Go wall time, before transfer/storage |
| In-server replacement, eight workers | n/a | 0.291 s | About 3.81x faster than the one-shot Go wall time, before transfer/storage |
Full ordered result through psql |
1.11 s streaming file output | 4.05 s | Client buffering and ordered row transfer dominate PostgreSQL path |
The 4.05-second psql result is a correctness path, not a recommended export
benchmark. psql/libpq buffered the huge ordinary SELECT result and reached
about 673 MiB client RSS. Production exports should use a streaming COPY
shape, and in-database applications should avoid exporting rows that can be
consumed or materialized in the server.
The Go and PostgreSQL implementations are also not feature-equivalent cost centers. The Go reference is a contest-specific replacer. The extension validates a portable artifact, supports text and bytea, preserves IDs and priorities, exposes byte and character coordinates, supports multiple match policies, enforces output limits, and operates through PostgreSQL's executor.
- Retained automaton arrays: 3,731,840 bytes.
- Measured post-GC automaton heap increment: 3,735,792 bytes (3.56 MiB).
- One-shot process peak RSS: roughly 11 MiB.
- I/O remains streaming; the normal replacement path does not retain the 322 MB input or output.
- The historical correctness helper is not streaming and should be excluded from production memory claims.
After acdat_prepare_runtime, the format-major-1 cached runtime releases the
materialized output_link array and retains four slot arrays, the root table,
terminals, and replacements. On this arm64 build the payload calculation is:
cached runtime = 120
+ 4 * slot_count * 4
+ 256 * 4
+ pattern_count * 40
+ replacement_bytes
Measured or calculated steady cached payloads:
| Dictionary shape | Stored artifact | Cached runtime per process | Nine-process budget (8 workers + leader) |
|---|---|---|---|
| Historical 1,114-rule dictionary | 269.2 KiB | 227.2 KiB | about 2.0 MiB |
| Generated 100K shared-prefix dictionary | 5.936 MiB | 5.512 MiB | about 49.6 MiB |
| Synthetic 100K random ASCII dictionary | 28.961 MiB | 23.933 MiB | about 215.4 MiB |
These values cover the steady cached automaton payload, not full PostgreSQL
backend RSS. First use also requires a detoasted artifact and validation
scratch space before the detoasted copy is released. matches and replace
add result memory proportional to the current input row and its match/output
count.
The historical source file was 306.9 MiB while its simple PostgreSQL heap was about 622 MiB, roughly a 2x storage/cache footprint before indexes. That is a table representation cost, not machine memory.
Command:
BENCH_REPEATS=3 BENCH_INPUT_MIB=16 make bench-matrixAll cells use the materialized runtime corresponding to PostgreSQL's cache path.
| 100K profile | Artifact | Runtime | Build | Materialize | Scan |
|---|---|---|---|---|---|
| ASCII miss | 28.961 MiB | 23.933 MiB | 98.584 ms | 7.989 ms | 72.57 MiB/s |
| Root-only miss | 27.054 MiB | 22.407 MiB | 65.295 ms | 7.632 ms | 965.43 MiB/s |
| ASCII hit-heavy | 28.961 MiB | 23.933 MiB | 96.183 ms | 8.092 ms | 361.60 MiB/s |
| UTF-8 byte miss | 28.673 MiB | 23.703 MiB | 92.937 ms | 8.915 ms | 971.11 MiB/s |
The large spread is workload-driven. Root misses avoid most state traversal; ordinary ASCII misses repeatedly traverse failure links and stress a much larger working set. Dictionary count alone is not a sufficient throughput predictor.
The PostgreSQL compile test used generated patterns of the form
pattern-NNNNNNNN. Their long shared prefix makes the machine much smaller
than the random-pattern standalone matrix, so build and size values must not be
compared as if the dictionaries were identical.
| Item | Result |
|---|---|
| Pattern count | 100,000 |
| States / slots | 111,127 / 111,191 |
| Aggregate compile | 36.910 ms |
| Compile plus CTAS/TOAST storage | 69.161 ms |
| Stored artifact | 6,224,012 bytes (5.936 MiB) |
| Cached runtime | 5,780,200 bytes (5.512 MiB) |
| One million generated short root-miss calls | 129.011 ms |
All three profiles scan roughly 256-314 MiB of root-miss text through the same 100K machine.
| Row shape | One backend | Four workers | Eight workers |
|---|---|---|---|
| About 33 B, 10M rows | 600.624 ms / 522 MiB/s | 140.086 ms / 2.19 GiB/s | 104.017 ms / 2.94 GiB/s |
| 1 KiB, 262,144 rows | 281.564 ms / 909 MiB/s | 73.290 ms / 3.41 GiB/s | 49.947 ms / 5.01 GiB/s |
| 64 KiB, 4,096 rows | 280.353 ms / 913 MiB/s | 70.504 ms / 3.55 GiB/s | 48.777 ms / 5.13 GiB/s |
The one-backend 1 KiB and 64 KiB results are about 94% of the 965 MiB/s standalone root-miss result. The 33-byte case reaches about 54%. This isolates the PostgreSQL per-row boundary: the scanner is near standalone speed once each call performs enough useful work.
The 10M short-row four-worker speedup was 4.29x, exceeding the 2.5x four-worker target in the PRD. The eight-worker speedup was 5.77x. A one-million-row test did not scale as well because parallel startup and per-worker machine setup were too large relative to only about 58 ms of one-backend work.
The direct relation join below was safe for correctness but produced a bad parallel plan in this test:
SELECT count(*)
FROM bench_doc AS d
CROSS JOIN bench_machine AS m
CROSS JOIN LATERAL acdat.matches(
d.line, m.machine, 'all_overlapping'
) AS h;With eight workers, PostgreSQL placed the one-row machine scan inside the
document loop. EXPLAIN (ANALYZE, BUFFERS) reported:
- 10,318,163 loops over
bench_machine; - more than 10.3 million buffer hits attributable to that scan;
- 10.43 seconds execution time.
Making the machine an uncorrelated scalar InitPlan avoided the rescan:
SELECT count(*)
FROM bench_doc AS d
CROSS JOIN LATERAL acdat.matches(
d.line,
(SELECT machine FROM bench_machine),
'all_overlapping'
) AS h;The corrected eight-worker query took 0.476 seconds, about 22x faster, with the
same 601,838 matches. Applications should publish or select a machine once per
query and inspect the parallel plan for large scans. A MATERIALIZED shape or
an equivalent InitPlan is preferable to a join shape that can be reordered
inside the document loop.
| Scenario | Recommended path | Reason |
|---|---|---|
| One-shot file replacement | Original Go tool or a dedicated streaming program | Lowest latency and about 11 MiB RSS |
| Data already in PostgreSQL, existence test only | acdat.contains |
Avoids match tuples and output construction |
| In-database redaction or normalization | acdat.replace with candidate filtering |
Keeps data local and parallelizes well |
| Entity or IOC extraction | acdat.matches with explicit machine InitPlan |
Returns IDs/positions; requires plan discipline |
| Wide documents or log payloads | PostgreSQL extension | Reaches about 94% of standalone core in one backend |
| Millions of tiny rows | Filter candidates first; use parallelism only for sufficiently large scans | Per-row executor overhead dominates |
| Full result export | Streaming COPY or application streaming |
Avoid ordinary SELECT buffering in clients |
| 100K+ machine with many workers | Budget runtime times participating processes | Each worker materializes a private machine copy |
This report does not establish:
- cold filesystem or cold TOAST latency;
- durable
INSERT,UPDATE, materialized-hit-table, WAL, or replication cost; - concurrent-client throughput or latency under mixed workloads;
- full backend peak RSS during large dictionary construction;
- Linux amd64 or arm64 performance;
- a modern large real-world CJK, URL, IOC, or binary-signature dictionary;
- comparison with regex alternation,
LIKE ANY,pg_trgm, or another AC implementation on the exact same dictionaries; - package-lab or production-host performance.
The historical corpus is useful because it provides exact Go compatibility and a stable full-output checksum, but 1,114 rules are far below the extension's 100K target. The synthetic matrix covers scale and execution shape; it does not replace a modern real dictionary benchmark.
Core and extension checks:
make core-test
make installcheck
make cache-smoke-test
BENCH_REPEATS=3 BENCH_INPUT_MIB=16 make bench-matrixRepresentative real-corpus PostgreSQL operators:
SELECT count(*) FILTER (WHERE acdat.contains(d.line, m.machine))
FROM bench_doc AS d
CROSS JOIN bench_machine AS m;
SELECT sum(octet_length(
acdat.replace(d.line, m.machine, 'leftmost_longest')
) + 1)
FROM bench_doc AS d
CROSS JOIN bench_machine AS m;
SELECT count(*)
FROM bench_doc AS d
CROSS JOIN LATERAL acdat.matches(
d.line,
(SELECT machine FROM bench_machine),
'all_overlapping'
) AS h;ACDAT 0.1.1 is performance-credible as an in-database exact literal-rule engine:
- correctness matches the original Go result over the complete historical corpus;
- build time and machine size are small;
- the cached C runtime is substantially more compact than the Go reference;
- single-backend short-row replacement pays a visible PostgreSQL boundary cost;
- wide-row throughput is close to the standalone core;
- four- and eight-worker scans scale well once the workload is large enough;
- query shape, result transfer, and per-worker memory multiplication matter as much as the matcher itself.
Use the Go implementation when the problem is a specialized streaming file job. Use the extension when the data and downstream work belong in PostgreSQL, and design the SQL plan so the machine is compiled or selected once and reused across the scan.