Skip to content

Commit a9eb52b

Browse files
costindweiss
andauthored
Add ASCII fast-path to CharacterUtils.toLowerCase (#16355)
* Add ASCII fast-path to CharacterUtils.toLowerCase Skip codepoint-level Unicode lowering for ASCII-only tokens by using direct arithmetic; 2.16x faster on English text. * Reverting cosmetic changes. * Remove unused import. --------- Co-authored-by: Dawid Weiss <dawid.weiss@carrotsearch.com>
1 parent 260c5f2 commit a9eb52b

3 files changed

Lines changed: 207 additions & 4 deletions

File tree

lucene/CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ Optimizations
331331

332332
* GITHUB#16352: Better cost() estimation for SkipBlockRangeIterator. (Alan Woodward)
333333

334+
* GITHUB#16355: Add ASCII fast-path to CharacterUtils.toLowerCase, avoiding per-char codepoint
335+
conversion for ASCII-only tokens. 2.16x throughput on English text. (Costin Leau)
336+
334337
Bug Fixes
335338
---------------------
336339
* GITHUB#16350: Disable bulk-scoring in monitor queries. (Alan Woodward)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.lucene.benchmark.jmh;
18+
19+
import java.util.Random;
20+
import java.util.concurrent.TimeUnit;
21+
import org.openjdk.jmh.annotations.Benchmark;
22+
import org.openjdk.jmh.annotations.BenchmarkMode;
23+
import org.openjdk.jmh.annotations.Fork;
24+
import org.openjdk.jmh.annotations.Level;
25+
import org.openjdk.jmh.annotations.Measurement;
26+
import org.openjdk.jmh.annotations.Mode;
27+
import org.openjdk.jmh.annotations.OutputTimeUnit;
28+
import org.openjdk.jmh.annotations.Param;
29+
import org.openjdk.jmh.annotations.Scope;
30+
import org.openjdk.jmh.annotations.Setup;
31+
import org.openjdk.jmh.annotations.State;
32+
import org.openjdk.jmh.annotations.Warmup;
33+
import org.openjdk.jmh.infra.Blackhole;
34+
35+
/**
36+
* Benchmark for {@code CharacterUtils.toLowerCase}. Compares the generic Unicode codepoint path
37+
* (codePointAt + toLowerCase + toChars per char) against an optimized ASCII fast path that uses
38+
* arithmetic bit manipulation for ASCII and only falls back to Unicode for non-ASCII characters.
39+
* Token lengths range from 3 to 10 characters.
40+
*/
41+
@BenchmarkMode(Mode.Throughput)
42+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
43+
@State(Scope.Thread)
44+
@Warmup(iterations = 5, time = 1)
45+
@Measurement(iterations = 10, time = 1)
46+
@Fork(3)
47+
public class LowerCaseBenchmark {
48+
49+
@Param({"1000"})
50+
int tokenCount;
51+
52+
@Param({"english", "ascii75", "ascii50", "ascii25", "allNonAscii"})
53+
String distribution;
54+
55+
private char[][] workBuffers;
56+
57+
private int[] workLengths;
58+
59+
private char[][] masterBuffers;
60+
61+
private int[] masterLengths;
62+
63+
@Setup(Level.Trial)
64+
public void setup() {
65+
Random rng = new Random(42);
66+
67+
masterBuffers = new char[tokenCount][];
68+
masterLengths = new int[tokenCount];
69+
70+
for (int t = 0; t < tokenCount; t++) {
71+
int len = 3 + rng.nextInt(8);
72+
masterBuffers[t] = generateToken(rng, len);
73+
masterLengths[t] = masterBuffers[t].length;
74+
}
75+
76+
workBuffers = new char[tokenCount][];
77+
workLengths = new int[tokenCount];
78+
for (int t = 0; t < tokenCount; t++) {
79+
workBuffers[t] = new char[masterBuffers[t].length];
80+
workLengths[t] = masterLengths[t];
81+
}
82+
}
83+
84+
private char[] generateToken(Random rng, int len) {
85+
return switch (distribution) {
86+
case "english" -> generateByAsciiPercent(rng, len, 0.98);
87+
case "ascii75" -> generateByAsciiPercent(rng, len, 0.75);
88+
case "ascii50" -> generateByAsciiPercent(rng, len, 0.50);
89+
case "ascii25" -> generateByAsciiPercent(rng, len, 0.25);
90+
case "allNonAscii" -> randomWithNonAscii(rng, len);
91+
default -> throw new IllegalArgumentException("Unknown distribution: " + distribution);
92+
};
93+
}
94+
95+
private static char[] generateByAsciiPercent(Random rng, int len, double asciiRatio) {
96+
if (rng.nextDouble() < asciiRatio) {
97+
return rng.nextBoolean() ? randomLowercaseAscii(rng, len) : randomMixedCaseAscii(rng, len);
98+
} else {
99+
return randomWithNonAscii(rng, len);
100+
}
101+
}
102+
103+
@Setup(Level.Invocation)
104+
public void resetBuffers() {
105+
for (int t = 0; t < tokenCount; t++) {
106+
System.arraycopy(masterBuffers[t], 0, workBuffers[t], 0, masterLengths[t]);
107+
}
108+
}
109+
110+
@Benchmark
111+
public void baseline(Blackhole bh) {
112+
for (int t = 0; t < tokenCount; t++) {
113+
toLowerCaseBaseline(workBuffers[t], 0, workLengths[t]);
114+
bh.consume(workBuffers[t]);
115+
}
116+
}
117+
118+
@Benchmark
119+
public void asciiFastPath(Blackhole bh) {
120+
for (int t = 0; t < tokenCount; t++) {
121+
toLowerCaseAsciiFastPath(workBuffers[t], 0, workLengths[t]);
122+
bh.consume(workBuffers[t]);
123+
}
124+
}
125+
126+
static void toLowerCaseBaseline(char[] buffer, int offset, int limit) {
127+
for (int i = offset; i < limit; ) {
128+
i +=
129+
Character.toChars(
130+
Character.toLowerCase(Character.codePointAt(buffer, i, limit)), buffer, i);
131+
}
132+
}
133+
134+
static void toLowerCaseAsciiFastPath(char[] buffer, int offset, int limit) {
135+
for (int i = offset; i < limit; i++) {
136+
char c = buffer[i];
137+
if (c > 127) {
138+
for (; i < limit; ) {
139+
i +=
140+
Character.toChars(
141+
Character.toLowerCase(Character.codePointAt(buffer, i, limit)), buffer, i);
142+
}
143+
return;
144+
}
145+
if (c >= 'A' && c <= 'Z') {
146+
buffer[i] = (char) (c | 32);
147+
}
148+
}
149+
}
150+
151+
private static char[] randomLowercaseAscii(Random rng, int len) {
152+
char[] buf = new char[len];
153+
for (int i = 0; i < len; i++) {
154+
buf[i] = (char) ('a' + rng.nextInt(26));
155+
}
156+
return buf;
157+
}
158+
159+
private static char[] randomMixedCaseAscii(Random rng, int len) {
160+
char[] buf = new char[len];
161+
buf[0] = (char) ('A' + rng.nextInt(26));
162+
for (int i = 1; i < len; i++) {
163+
buf[i] = (char) ('a' + rng.nextInt(26));
164+
}
165+
return buf;
166+
}
167+
168+
private static char[] randomWithNonAscii(Random rng, int len) {
169+
char[] buf = new char[len];
170+
for (int i = 0; i < len; i++) {
171+
double r = rng.nextDouble();
172+
if (r < 0.4) {
173+
buf[i] = (char) ('a' + rng.nextInt(26));
174+
} else if (r < 0.7) {
175+
char c;
176+
do {
177+
c = (char) (0x00C0 + rng.nextInt(31));
178+
} while (c == 0x00D7);
179+
buf[i] = c;
180+
} else if (r < 0.9) {
181+
char c;
182+
do {
183+
c = (char) (0x00E0 + rng.nextInt(31));
184+
} while (c == 0x00F7);
185+
buf[i] = c;
186+
} else {
187+
buf[i] = (char) (0x4E00 + rng.nextInt(100));
188+
}
189+
}
190+
return buf;
191+
}
192+
}

lucene/core/src/java/org/apache/lucene/analysis/CharacterUtils.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,18 @@ public static CharacterBuffer newCharacterBuffer(final int bufferSize) {
5353
public static void toLowerCase(final char[] buffer, final int offset, final int limit) {
5454
assert buffer.length >= limit;
5555
assert 0 <= offset && offset <= buffer.length;
56-
for (int i = offset; i < limit; ) {
57-
i +=
58-
Character.toChars(
59-
Character.toLowerCase(Character.codePointAt(buffer, i, limit)), buffer, i);
56+
for (int i = offset; i < limit; i++) {
57+
char c = buffer[i];
58+
if (c > 127) { // non-ASCII: switch to full Unicode path from here onward
59+
while (i < limit) {
60+
i +=
61+
Character.toChars(
62+
Character.toLowerCase(Character.codePointAt(buffer, i, limit)), buffer, i);
63+
}
64+
return;
65+
} else if (c >= 'A' && c <= 'Z') {
66+
buffer[i] = (char) (c | 32); // set bit 5 to lowercase ASCII
67+
}
6068
}
6169
}
6270

0 commit comments

Comments
 (0)