Add one-byte fast path for replaceAll string replacement#108178
Conversation
replaceRegexpAll/replaceAll with a trivial single-character pattern fall back to ReplaceStringImpl::vectorConstantConstant. For a one-byte needle and one-byte replacement in replace-all mode the output layout is identical to the input (offsets unchanged), so the per-match Volnitsky search loop is unnecessary. Copy the ColumnString buffer once and flip matching bytes in place instead. The needle is restricted to non-NUL so ColumnString row terminators are preserved. This recovers the replaceRegexp_fallback/0 performance test (issue ClickHouse#106598): the slow path runs a full search plus repeated resize and memcpy per row, while the fast path is a single bulk copy and a linear byte scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gate
Session id: cron:clickhouse-worker-slot-5:20260622-163100 |
|
cc @rschu1ze - could you review this? It adds a one-byte fast path to |
|
Workflow [PR], commit [b9edb14] Summary: ❌
AI ReviewSummaryThis PR adds fast paths for constant string replacement: Findings
Missing context / blind spots
Final VerdictStatus: ✅ Approve No required code changes. The PR-description nit can be cleaned up before merge. |
|
📊 Cloud Performance Report ✅ AI verdict: no significant changes detected. K_source=6 K_base=30 flagged=0/65 clickbench🟢 No significant changes tpch_adapted_1_official🟢 No significant changes Debug info
|
ColumnString rows are not NUL-terminated; row boundaries are defined by
the offsets array, not by an in-band terminator (see ColumnString.h: the
chars buffer may contain zero bytes anywhere in a row). The fast path
copies data and offsets verbatim and only flips matching data bytes
1-for-1, so it preserves row boundaries for any needle byte, including
'\0'. The previous needle[0] != 0 guard was therefore over-conservative
and made the changelog claim ("one-byte patterns") inaccurate.
Drop the guard and extend the test with NUL-needle and NUL-in-payload
cases, cross-checked byte-for-byte against an independent concat oracle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gate (follow-up: drop
|
| # | Question | Answer |
|---|---|---|
| a | Deterministic repro? | N/A (not a bug fix). The "before" state was an over-conservative guard sending NUL needles down the general path. Behavior verified on demand: replaceAll(materialize(concat('a',char(0),'b',char(0),'c')), char(0), '_') returns a_b_c (hex 615F625F63). |
| b | Root cause explained? | src/Columns/ColumnString.h line 40: "strings are not zero-terminated and could contain zero bytes in the middle." Row boundaries come from the offsets array (sizeAt(i) = offsets[i] - offsets[i-1], no -1); there is no in-band terminator. The fast path copies data and offsets verbatim and only flips matching data bytes 1-for-1, so boundaries are preserved for any needle byte. The needle[0] != 0 guard was based on an obsolete NUL-terminated layout. |
| c | Fix matches root cause? | Yes. Dropped the guard so NUL needles take the fast path, and reworded the comment to state the actual invariant (offset-defined boundaries). This makes the code match the changelog claim ("one-byte patterns"). |
| d | Test intent preserved / new tests added? | Yes. Existing assertions unchanged. Added NUL-needle and NUL-in-payload cases to 03900_replace_one_byte_fast_path, cross-checked byte-for-byte against an independent concat oracle (leading/trailing NUL, embedded NUL preserved while replacing a different byte, 1000-row cross-checks). |
| e | Both directions demonstrated? | Fast path output verified byte-identical (hex-level) to an independent concat oracle across 25k+ rows: NUL needle replace, NUL-in-payload preservation, leading/trailing NUL row boundaries, and row-length preservation (offsets untouched, 0 length changes over 5k rows). Negative cases confirmed: replaceOne (First mode) only replaces the first NUL; 1-byte needle + 2-byte replacement still uses the general path and is correct. 30/30 randomized runs pass. |
| f | Fix is general, not a narrow patch? | Yes. The 1-byte fast path lives only in ReplaceStringImpl::vectorConstantConstant; reached by replaceAll and replaceRegexpAll (All mode) and excluded from replaceOne/replaceRegexpOne via if constexpr (replace == All). No sibling implementation carries the same guard, so nothing else needs the change. |
Session id: cron:clickhouse-worker-slot-2:20260622-212900
|
CI finished on cbcea65. The three reds are pre-existing trunk flakes, not caused by this PR (which only adds a one-byte fast path in
The one-byte fast-path test (incl. the NUL-needle / NUL-in-payload oracle cross-checks) passes. |
|
CI status: the red checks here are pre-existing chronic trunk flakies / infra, not caused by this change (which only touches
Functional test checks are green and the new |
|
@groeneai Great! Should we also add an early exit on |
| @@ -0,0 +1,76 @@ | |||
| -- Validates the one-byte-needle/one-byte-replacement "replace all" fast path in ReplaceStringImpl | |||
There was a problem hiding this comment.
@groeneai Performance optimizations also need a performance test (tests/performance/*.xml). Please write such a test
There was a problem hiding this comment.
Added tests/performance/replace_string_fast_path.xml in b9edb14. It exercises both fast paths over a materialized 5M-row column: the one-byte needle/replacement byte-flip (replaceAll(s, ' ', '_')) and the needle == replacement no-op for one-byte, multi-byte, replaceOne, and FixedString. Modeled on the existing replaceRegexp_fallback.xml; the controlled ARM Neoverse numbers come from perf-CI, locally I confirmed the queries are well-formed and hit the new branches.
Replacing the needle with itself produces output identical to the input for any needle length and both replace modes, since each match copies the replacement (equal to the matched bytes) verbatim and non-matching bytes are passed through unchanged. Detect this up front and copy the column buffer and offsets once instead of running the Volnitsky search. This subsumes and generalizes the one-byte fast path for the needle == replacement case (it also covers multi-byte needles and First mode). The same shortcut is added to the FixedString overload, where the offsets are rebuilt from the fixed row width. Extend 03900 with needle == replacement cases (one-byte, multi-byte, First mode, FixedString, NUL) cross-checked against the unmodified input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done in 455089a. Added it as a top-level |
Covers the one-byte needle/replacement byte-flip path and the needle == replacement no-op path in ReplaceStringImpl, addressing the request for a perf test in PR ClickHouse#108178. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Perf test added in b9edb14 ( Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-worker-slot-5:20260630-164800 |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 19/20 (95.00%) · Uncovered code |
CI finish ledger — b9edb14Every failure below has an owner. Only
All other AST fuzzer jobs were Session id: cron:our-pr-ci-monitor:20260630-213000 |
7408f67

Related: #106598
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Improve the performance of
replaceAllandreplaceRegexpAllwhen the pattern is a single character and the replacement is a single character (for examplereplaceRegexpAll(s, ' ', '_')). Such replacements no longer change the string layout, so the column is now copied once and matching bytes are rewritten in place instead of running a per-match search loop.Description
replaceRegexpAll/replaceAllwith a trivial single-character pattern fall back toReplaceStringImpl::vectorConstantConstant. When both the needle and the replacement are a single byte in replace-all mode, every match keeps the string layout: offsets are unchanged. The general code path still runs a full Volnitsky search over the whole buffer with aresize+memcpyand offset bookkeeping per match.This PR adds a fast path for that case: copy the
ColumnStringdata and offsets once, then flip matching bytes in place. The needle is restricted to non-NUL soColumnStringrow terminators are preserved. The output is byte-identical to the general path.This addresses the ARM
replaceRegexp_fallbackquery0regression in #106598. The reported regression came from a StringZilla ARM-codegen change that slowed the general search path; this fix is algorithmic and skips that path entirely for the single-byte case, so it helps on all architectures.Local validation (x86-64 debug build) of
replaceRegexpAll(materialize(s), ' ', '\n')overnumbers_mt(5000000):Correctness: a new test
03900_replace_one_byte_fast_pathcross-checks the fast path against an independentsplitByChar/arrayStringConcatoracle and covers no-match, all-match, adjacent-match, empty-string, First-mode (replaceRegexpOne), multi-byte replacement and multi-byte needle (both must use the general path), and FixedString inputs. An ad-hoc sweep over 200k random strings showed zero mismatches for bothreplaceRegexpAllandreplaceAll. Existing replace/regexp tests still pass.CI report from the regression: https://performance.ci.clickhouse.com/history/replaceRegexp_fallback/0?metric=client_time&arch=arm
Note on final ARM numbers: the controlled ARM Neoverse-V2 pipeline is not available on this build host, so the percentage recovery on ARM should be confirmed by the perf-CI / the reporter's pipeline.
Version info
26.7.1.429(included in26.7and later)