Skip to content

Commit f6066a1

Browse files
authored
Improve Python version parsing performance (#318)
* Pre-compiles regex patterns on the class level instead of re-compiling them on every invocation. * Short-circuits normalization when the provided version is simple. * Updates benchmark setups to capture simple and complex cases. Signed-off-by: nscuro <nscuro@protonmail.com>
1 parent 76b03b3 commit f6066a1

3 files changed

Lines changed: 104 additions & 23 deletions

File tree

versatile-benchmark/src/main/java/io/github/nscuro/versatile/benchmark/VersionCompareBenchmark.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.openjdk.jmh.annotations.State;
3333
import org.openjdk.jmh.annotations.Warmup;
3434

35+
import java.util.Map;
3536
import java.util.concurrent.TimeUnit;
3637

3738
@State(Scope.Thread)
@@ -42,6 +43,19 @@
4243
@Fork(2)
4344
public class VersionCompareBenchmark {
4445

46+
private static final Map<String, String[]> COMPLEX_PAIR_BY_SCHEME =
47+
Map.ofEntries(
48+
Map.entry("apk", new String[]{"1.2.3_alpha1-r1", "1.2.3_alpha2-r1"}),
49+
Map.entry("composer", new String[]{"1.2.3-beta1", "1.2.3-beta2"}),
50+
Map.entry("deb", new String[]{"1:1.2.3-1ubuntu0.1", "1:1.2.3-1ubuntu0.2"}),
51+
Map.entry("generic", new String[]{"1.2.3-beta1", "1.2.3-beta2"}),
52+
Map.entry("golang", new String[]{"v1.2.3-beta.1", "v1.2.3-beta.2"}),
53+
Map.entry("maven", new String[]{"1.2.3-rc1", "1.2.3-rc2"}),
54+
Map.entry("npm", new String[]{"1.2.3-beta.1", "1.2.3-beta.2"}),
55+
Map.entry("nuget", new String[]{"1.2.3-beta.1", "1.2.3-beta.2"}),
56+
Map.entry("pypi", new String[]{"1.2.3a1.dev2", "1.2.3a1.dev3"}),
57+
Map.entry("rpm", new String[]{"1:1.2.3-1.el8", "1:1.2.3-2.el8"}));
58+
4559
@Param({
4660
"apk",
4761
"composer",
@@ -56,13 +70,28 @@ public class VersionCompareBenchmark {
5670
})
5771
private String scheme;
5872

73+
@Param({"SIMPLE", "COMPLEX"})
74+
private String complexity;
75+
5976
private Version left;
6077
private Version right;
6178

6279
@Setup
6380
public void setup() {
64-
this.left = VersionFactory.forScheme(scheme, "1.2.3");
65-
this.right = VersionFactory.forScheme(scheme, "1.2.4");
81+
final String leftStr;
82+
final String rightStr;
83+
84+
if ("SIMPLE".equals(complexity)) {
85+
leftStr = "1.2.3";
86+
rightStr = "1.2.4";
87+
} else {
88+
final String[] pair = COMPLEX_PAIR_BY_SCHEME.get(scheme);
89+
leftStr = pair[0];
90+
rightStr = pair[1];
91+
}
92+
93+
this.left = VersionFactory.forScheme(scheme, leftStr);
94+
this.right = VersionFactory.forScheme(scheme, rightStr);
6695
}
6796

6897
@Benchmark

versatile-benchmark/src/main/java/io/github/nscuro/versatile/benchmark/VersionParsingBenchmark.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,30 @@
4343
@Fork(2)
4444
public class VersionParsingBenchmark {
4545

46-
private static final Map<String, String> VERSION_BY_SCHEME =
46+
private static final Map<String, String> SIMPLE_BY_SCHEME =
4747
Map.ofEntries(
48-
Map.entry("apk", "1.2.3-r1"),
48+
Map.entry("apk", "1.2.3"),
4949
Map.entry("composer", "1.2.3"),
50-
Map.entry("deb", "1.2.3-1"),
50+
Map.entry("deb", "1.2.3"),
5151
Map.entry("generic", "1.2.3"),
5252
Map.entry("golang", "v1.2.3"),
5353
Map.entry("maven", "1.2.3"),
5454
Map.entry("npm", "1.2.3"),
5555
Map.entry("nuget", "1.2.3"),
5656
Map.entry("pypi", "1.2.3"),
57-
Map.entry("rpm", "1.2.3-1"));
57+
Map.entry("rpm", "1.2.3"));
58+
private static final Map<String, String> COMPLEX_BY_SCHEME =
59+
Map.ofEntries(
60+
Map.entry("apk", "1.2.3_alpha1-r1"),
61+
Map.entry("composer", "1.2.3-beta1"),
62+
Map.entry("deb", "1:1.2.3-1ubuntu0.1"),
63+
Map.entry("generic", "1.2.3-beta1"),
64+
Map.entry("golang", "v1.2.3-beta.1+build.5"),
65+
Map.entry("maven", "1.2.3-rc.1-SNAPSHOT"),
66+
Map.entry("npm", "1.2.3-beta.1"),
67+
Map.entry("nuget", "1.2.3-beta.1+meta"),
68+
Map.entry("pypi", "1.2.3a1.dev2+local.1"),
69+
Map.entry("rpm", "1:1.2.3-1.el8"));
5870

5971
@Param({
6072
"apk",
@@ -70,11 +82,16 @@ public class VersionParsingBenchmark {
7082
})
7183
private String scheme;
7284

85+
@Param({"SIMPLE", "COMPLEX"})
86+
private String complexity;
87+
7388
private String versionStr;
7489

7590
@Setup
7691
public void setup() {
77-
this.versionStr = VERSION_BY_SCHEME.get(scheme);
92+
this.versionStr = "SIMPLE".equals(complexity)
93+
? SIMPLE_BY_SCHEME.get(scheme)
94+
: COMPLEX_BY_SCHEME.get(scheme);
7895
}
7996

8097
@Benchmark

versatile-core/src/main/java/io/github/nscuro/versatile/version/PythonVersion.java

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ public enum Type {
6767
(?:\\+(?<local>[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*))?\
6868
$""",
6969
Pattern.CASE_INSENSITIVE);
70+
private static final Pattern NORM_PATTERN_LEADING_V = Pattern.compile("^[vV]");
71+
private static final Pattern NORM_PATTERN_EPOCH = Pattern.compile("^([0-9]+)!");
72+
private static final Pattern NORM_PATTERN_ALPHA = Pattern.compile("[-_.]?(alpha)");
73+
private static final Pattern NORM_PATTERN_BETA = Pattern.compile("[-_.]?(beta)");
74+
private static final Pattern NORM_PATTERN_RC = Pattern.compile("[-_.]?(c|pre|preview)");
75+
private static final Pattern NORM_PATTERN_PRE_NUM = Pattern.compile("(a|b|rc)[-_.]?([0-9]+)");
76+
private static final Pattern NORM_PATTERN_PRE_IMPLICIT_NUM = Pattern.compile("(a|b|rc)(?![0-9])");
77+
private static final Pattern NORM_PATTERN_POST = Pattern.compile("[-_.]?(post|rev|r)[-_.]?([0-9]+)");
78+
private static final Pattern NORM_PATTERN_POST_IMPLICIT = Pattern.compile("-([0-9]+)(?!.*post)");
79+
private static final Pattern NORM_PATTERN_DEV = Pattern.compile("[-_.]?dev[-_.]?([0-9]+)");
80+
private static final Pattern NORM_PATTERN_DEV_IMPLICIT_NUM = Pattern.compile("[-_.]?dev(?![0-9])");
81+
private static final Pattern NORM_PATTERN_LOCAL_SEP = Pattern.compile("\\+([a-zA-Z0-9]+)[-_.]");
82+
private static final Pattern LOCAL_SEGMENT_SEPARATOR_PATTERN = Pattern.compile("\\.");
83+
private static final Pattern LOCAL_NORMALIZE_PATTERN = Pattern.compile("[-_]");
84+
private static final Pattern ASCII_DIGITS_PATTERN = Pattern.compile("\\d+");
7085

7186
private final int epoch;
7287
private final List<Integer> release;
@@ -187,36 +202,56 @@ private static String normalize(final String versionStr) {
187202
// https://peps.python.org/pep-0440/#leading-and-trailing-whitespace
188203
String normalized = versionStr.strip();
189204

205+
// Avoid the heavy regex machinery when the version is simple, e.g. `666` or `6.6.6`.
206+
if (isSimpleNumericVersion(normalized)) {
207+
return normalized;
208+
}
209+
190210
// https://peps.python.org/pep-0440/#preceding-v-character
191-
normalized = normalized.replaceFirst("^[vV]", "");
211+
normalized = NORM_PATTERN_LEADING_V.matcher(normalized).replaceFirst("");
192212

193213
// https://peps.python.org/pep-0440/#integer-normalization
194-
normalized = normalized.replaceAll("^([0-9]+)!", "$1!");
214+
normalized = NORM_PATTERN_EPOCH.matcher(normalized).replaceAll("$1!");
195215

196216
// https://peps.python.org/pep-0440/#pre-release-separators
197217
// https://peps.python.org/pep-0440/#pre-release-spelling
198-
normalized = normalized.replaceAll("[-_.]?(alpha)", "a");
199-
normalized = normalized.replaceAll("[-_.]?(beta)", "b");
200-
normalized = normalized.replaceAll("[-_.]?(c|pre|preview)", "rc");
201-
normalized = normalized.replaceAll("(a|b|rc)[-_.]?([0-9]+)", "$1$2");
202-
normalized = normalized.replaceAll("(a|b|rc)(?![0-9])", "$10");
218+
normalized = NORM_PATTERN_ALPHA.matcher(normalized).replaceAll("a");
219+
normalized = NORM_PATTERN_BETA.matcher(normalized).replaceAll("b");
220+
normalized = NORM_PATTERN_RC.matcher(normalized).replaceAll("rc");
221+
normalized = NORM_PATTERN_PRE_NUM.matcher(normalized).replaceAll("$1$2");
222+
normalized = NORM_PATTERN_PRE_IMPLICIT_NUM.matcher(normalized).replaceAll("$10");
203223

204224
// https://peps.python.org/pep-0440/#post-release-separators
205225
// https://peps.python.org/pep-0440/#post-release-spelling
206-
normalized = normalized.replaceAll("[-_.]?(post|rev|r)[-_.]?([0-9]+)", ".post$2");
207-
normalized = normalized.replaceAll("-([0-9]+)(?!.*post)", ".post$1");
226+
normalized = NORM_PATTERN_POST.matcher(normalized).replaceAll(".post$2");
227+
normalized = NORM_PATTERN_POST_IMPLICIT.matcher(normalized).replaceAll(".post$1");
208228

209229
// https://peps.python.org/pep-0440/#development-release-separators
210-
normalized = normalized.replaceAll("[-_.]?dev[-_.]?([0-9]+)", ".dev$1");
211-
normalized = normalized.replaceAll("[-_.]?dev(?![0-9])", ".dev0");
230+
normalized = NORM_PATTERN_DEV.matcher(normalized).replaceAll(".dev$1");
231+
normalized = NORM_PATTERN_DEV_IMPLICIT_NUM.matcher(normalized).replaceAll(".dev0");
212232

213233
// https://peps.python.org/pep-0440/#local-version-segments
214-
normalized = normalized.replaceAll("\\+([a-zA-Z0-9]+)[-_.]", "+$1.");
234+
normalized = NORM_PATTERN_LOCAL_SEP.matcher(normalized).replaceAll("+$1.");
215235

216236
// https://peps.python.org/pep-0440/#case-sensitivity
217237
return normalized.toLowerCase(Locale.ROOT);
218238
}
219239

240+
private static boolean isSimpleNumericVersion(String version) {
241+
if (version.isEmpty()) {
242+
return false;
243+
}
244+
245+
for (int i = 0; i < version.length(); i++) {
246+
final char c = version.charAt(i);
247+
if ((c < '0' || c > '9') && c != '.') {
248+
return false;
249+
}
250+
}
251+
252+
return true;
253+
}
254+
220255
private static int parseEpoch(final String epochStr) {
221256
if (epochStr == null || epochStr.isBlank()) {
222257
return 0;
@@ -288,7 +323,7 @@ private static String parseLocal(final String localStr) {
288323
return null;
289324
}
290325

291-
return localStr.toLowerCase(Locale.ROOT).replaceAll("[-_]", ".");
326+
return LOCAL_NORMALIZE_PATTERN.matcher(localStr.toLowerCase(Locale.ROOT)).replaceAll(".");
292327
}
293328

294329
private static int compareRelease(final List<Integer> release1, final List<Integer> release2) {
@@ -390,16 +425,16 @@ private static int compareLocal(final String local1, final String local2) {
390425
return 1;
391426
}
392427

393-
final String[] parts1 = local1.split("\\.");
394-
final String[] parts2 = local2.split("\\.");
428+
final String[] parts1 = LOCAL_SEGMENT_SEPARATOR_PATTERN.split(local1);
429+
final String[] parts2 = LOCAL_SEGMENT_SEPARATOR_PATTERN.split(local2);
395430
final int maxLen = Math.max(parts1.length, parts2.length);
396431

397432
for (int i = 0; i < maxLen; i++) {
398433
final String p1 = i < parts1.length ? parts1[i] : "";
399434
final String p2 = i < parts2.length ? parts2[i] : "";
400435

401436
final int result;
402-
if (p1.matches("\\d+") && p2.matches("\\d+")) {
437+
if (ASCII_DIGITS_PATTERN.matcher(p1).matches() && ASCII_DIGITS_PATTERN.matcher(p2).matches()) {
403438
result = Integer.compare(Integer.parseInt(p1), Integer.parseInt(p2));
404439
} else {
405440
result = p1.compareTo(p2);

0 commit comments

Comments
 (0)