Skip to content

Commit 8d9900e

Browse files
authored
TIKA-4745 -- efficiency improvements (#2878)
1 parent 1849dc7 commit 8d9900e

32 files changed

Lines changed: 642 additions & 314 deletions

File tree

tika-core/src/main/java/org/apache/tika/detect/EncodingDetectorContext.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@
4040
public class EncodingDetectorContext {
4141

4242
private final List<Result> results = new ArrayList<>();
43+
private final EncodingProbeCache probeCache = new EncodingProbeCache();
4344
private String arbitrationInfo;
4445

46+
/**
47+
* Per-detection cache of the raw detection probe, shared across the detectors in
48+
* this chain so they don't each re-read the same leading bytes. It lives and dies
49+
* with this context (which is removed after detection), so it never leaks into
50+
* recursive/attachment parsing.
51+
*/
52+
public EncodingProbeCache getProbeCache() {
53+
return probeCache;
54+
}
55+
4556
/**
4657
* Record the ranked results from a child detector.
4758
*
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.tika.detect;
18+
19+
/**
20+
* Caches the raw encoding-detection probe (the leading bytes read for detection)
21+
* so that multiple detectors in a chain do not each re-read and re-tag-strip the
22+
* same bytes. For example a statistical detector and a downstream meta detector
23+
* that re-reads the bytes for arbitration can share one probe.
24+
* <p>
25+
* An instance is held by {@link EncodingDetectorContext}, so it inherits that
26+
* context's per-detection lifecycle: created fresh per detection and discarded
27+
* with the context immediately afterwards. That matters because a
28+
* {@link org.apache.tika.parser.ParseContext} flows on into recursive
29+
* (attachment/embedded) parsing — a probe must never outlive the single detection
30+
* it was read for.
31+
* <p>
32+
* Not thread-safe: a single detection runs its detectors sequentially on one
33+
* thread. The cache is keyed by the probe parameters — {@link #get} returns the
34+
* cached probe only when both {@code contentTarget} and {@code rawCap} match what
35+
* it was stored with, so a detector that wants a differently-sized probe
36+
* transparently reads (and caches) its own.
37+
* <p>
38+
* The cached array is shared read-only state; callers must not mutate it in place.
39+
*/
40+
public class EncodingProbeCache {
41+
42+
private byte[] probe;
43+
private int contentTarget = -1;
44+
private int rawCap = -1;
45+
46+
/**
47+
* @return the cached probe if one was stored with the same {@code contentTarget} and
48+
* {@code rawCap}; otherwise {@code null}
49+
*/
50+
public byte[] get(int contentTarget, int rawCap) {
51+
if (probe != null && this.contentTarget == contentTarget && this.rawCap == rawCap) {
52+
return probe;
53+
}
54+
return null;
55+
}
56+
57+
/**
58+
* Stores the probe bytes read with the given parameters.
59+
*/
60+
public void put(byte[] probe, int contentTarget, int rawCap) {
61+
this.probe = probe;
62+
this.contentTarget = contentTarget;
63+
this.rawCap = rawCap;
64+
}
65+
}

tika-encoding-detectors/tika-encoding-detector-html/src/main/java/org/apache/tika/parser/html/HtmlEncodingDetector.java

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import java.io.IOException;
2121
import java.io.InputStreamReader;
2222
import java.io.Serializable;
23-
import java.nio.ByteBuffer;
2423
import java.nio.charset.Charset;
2524
import java.nio.charset.StandardCharsets;
2625
import java.util.Collections;
@@ -85,6 +84,7 @@ public void setMarkLimit(int markLimit) {
8584
private static final Pattern FLEXIBLE_CHARSET_ATTR_PATTERN =
8685
Pattern.compile(("(?is)\\bcharset\\s*=\\s*(?:['\\\"]\\s*)?([-_:\\.a-z0-9]+)"));
8786
private static final Charset ASCII = Charset.forName("US-ASCII");
87+
private static final Pattern HTML_COMMENT_PATTERN = Pattern.compile("<!--.*?(-->|$)");
8888
/**
8989
* HTML can include non-iana supported charsets that Java
9090
* recognizes, e.g. "unicode". This can lead to incorrect detection/mojibake.
@@ -162,10 +162,20 @@ public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
162162
}
163163
tis.reset();
164164

165-
String head = ASCII.decode(ByteBuffer.wrap(buffer, 0, n)).toString();
166-
String headNoComments = head.replaceAll("<!--.*?(-->|$)", " ");
165+
// findCharset only ever matches a meta tag (HTTP_META_PATTERN = "<\s*meta...").
166+
// If the probe has no such tag, the full ASCII decode + comment-stripping
167+
// regex below can only produce null — skip them. Byte-level, no allocation;
168+
// a strict necessary condition for any non-empty result.
169+
if (!containsMetaTag(buffer, n)) {
170+
return Collections.emptyList();
171+
}
172+
173+
String head = new String(buffer, 0, n, ASCII);
174+
boolean hasComment = head.indexOf("<!--") >= 0;
175+
String headNoComments =
176+
hasComment ? HTML_COMMENT_PATTERN.matcher(head).replaceAll(" ") : head;
167177
Charset charset = findCharset(headNoComments);
168-
if (charset == null) {
178+
if (charset == null && hasComment) {
169179
charset = findCharset(head);
170180
}
171181
if (charset == null) {
@@ -175,6 +185,39 @@ public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
175185
EncodingResult.ResultType.DECLARATIVE));
176186
}
177187

188+
/**
189+
* Byte-level scan for an opening meta tag, mirroring the {@code <\s*meta} prefix of
190+
* {@link #HTTP_META_PATTERN} (ASCII, case-insensitive). Lets {@link #detect} skip the
191+
* full ASCII decode + comment-stripping regex on probes that cannot contain a meta
192+
* charset declaration. {@code <}, ASCII whitespace and {@code meta} are all ASCII, so a
193+
* raw-byte scan is equivalent to scanning the decoded head.
194+
*/
195+
private static boolean containsMetaTag(byte[] buf, int len) {
196+
for (int i = 0; i < len; i++) {
197+
if (buf[i] != '<') {
198+
continue;
199+
}
200+
int j = i + 1;
201+
while (j < len) {
202+
int c = buf[j] & 0xFF;
203+
if (c == ' ' || c == '\t' || c == '\n' || c == 0x0B || c == '\f'
204+
|| c == '\r') {
205+
j++;
206+
} else {
207+
break;
208+
}
209+
}
210+
if (j + 4 <= len
211+
&& (((buf[j] & 0xFF) | 0x20) == 'm')
212+
&& (((buf[j + 1] & 0xFF) | 0x20) == 'e')
213+
&& (((buf[j + 2] & 0xFF) | 0x20) == 't')
214+
&& (((buf[j + 3] & 0xFF) | 0x20) == 'a')) {
215+
return true;
216+
}
217+
}
218+
return false;
219+
}
220+
178221
//returns null if no charset was found
179222
private Charset findCharset(String s) {
180223

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/main/resources/org/apache/tika/parser/html/StandardCharsets_unsupported_by_IANA.txt renamed to tika-encoding-detectors/tika-encoding-detector-html/src/main/resources/org/apache/tika/parser/html/StandardCharsets_unsupported_by_IANA.txt

File renamed without changes.

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/test/java/org/apache/tika/parser/html/HtmlEncodingDetectorTest.java renamed to tika-encoding-detectors/tika-encoding-detector-html/src/test/java/org/apache/tika/parser/html/HtmlEncodingDetectorTest.java

File renamed without changes.

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/test/java/org/apache/tika/parser/html/StandardHtmlEncodingDetectorTest.java renamed to tika-encoding-detectors/tika-encoding-detector-html/src/test/java/org/apache/tika/parser/html/StandardHtmlEncodingDetectorTest.java

File renamed without changes.

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/AdaptiveProbe.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,20 @@ public static byte[] read(TikaInputStream tis, int contentTarget, int rawCap)
5555
throws IOException {
5656
tis.mark(rawCap);
5757
try {
58-
byte[] buf = new byte[rawCap];
59-
byte[] stripDst = new byte[rawCap];
58+
// Grow on demand rather than allocating (and zeroing) the full rawCap
59+
// (e.g. 512 KB) twice up front: the vast majority of probes are far
60+
// smaller. Bytes returned are identical to the eager-allocation version.
61+
int cap = Math.min(rawCap, contentTarget);
62+
byte[] buf = new byte[cap];
63+
byte[] stripDst = new byte[cap];
6064
int total = 0;
6165
while (total < rawCap) {
6266
int want = Math.min(rawCap - total, contentTarget);
67+
if (total + want > buf.length) {
68+
int newCap = Math.min(rawCap, Math.max(buf.length * 2, total + want));
69+
buf = Arrays.copyOf(buf, newCap);
70+
stripDst = Arrays.copyOf(stripDst, newCap);
71+
}
6372
int n = IOUtils.read(tis, buf, total, want);
6473
total += n;
6574
HtmlByteStripper.Result r =
@@ -72,7 +81,7 @@ public static byte[] read(TikaInputStream tis, int contentTarget, int rawCap)
7281
if (total == 0) {
7382
return new byte[0];
7483
}
75-
return total == rawCap ? buf : Arrays.copyOf(buf, total);
84+
return total == buf.length ? buf : Arrays.copyOf(buf, total);
7685
} finally {
7786
tis.reset();
7887
}

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929

3030
import org.apache.tika.config.TikaComponent;
3131
import org.apache.tika.detect.EncodingDetector;
32+
import org.apache.tika.detect.EncodingDetectorContext;
33+
import org.apache.tika.detect.EncodingProbeCache;
3234
import org.apache.tika.detect.EncodingResult;
3335
import org.apache.tika.detect.HighByteLetterStats;
3436
import org.apache.tika.io.TikaInputStream;
@@ -208,7 +210,7 @@ private static NaiveBayesBigramEncodingDetector loadFromClasspath() throws IOExc
208210
@Override
209211
public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
210212
ParseContext parseContext) throws IOException {
211-
byte[] probe = readProbe(tis);
213+
byte[] probe = readProbe(tis, parseContext);
212214
return detect(probe, metadata);
213215
}
214216

@@ -749,7 +751,21 @@ private static boolean shouldTryStrip(String contentType) {
749751
return lower.contains("html") || lower.contains("xml");
750752
}
751753

752-
private static byte[] readProbe(TikaInputStream tis) throws IOException {
753-
return AdaptiveProbe.read(tis, PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
754+
private static byte[] readProbe(TikaInputStream tis, ParseContext parseContext)
755+
throws IOException {
756+
EncodingDetectorContext context =
757+
parseContext == null ? null : parseContext.get(EncodingDetectorContext.class);
758+
EncodingProbeCache cache = context == null ? null : context.getProbeCache();
759+
if (cache != null) {
760+
byte[] cached = cache.get(PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
761+
if (cached != null) {
762+
return cached;
763+
}
764+
}
765+
byte[] probe = AdaptiveProbe.read(tis, PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
766+
if (cache != null) {
767+
cache.put(probe, PROBE_CONTENT_TARGET, PROBE_RAW_CAP);
768+
}
769+
return probe;
754770
}
755771
}

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/NaiveBayesBigramEncodingDetector.java

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,11 @@ public class NaiveBayesBigramEncodingDetector implements EncodingDetector {
6262
private static final int BIGRAM_SPACE = 65_536;
6363

6464
/**
65-
* Cap probe scanning at 10 KB. Bigram-based identification
65+
* Cap probe scanning at 16 KB. Bigram-based identification
6666
* saturates quickly — beyond the first 500-1000 bytes every
6767
* additional bigram nudges scores by &lt; 0.1 log-likelihood and
68-
* doesn't change the argmax. Reducing the cap from 4 KB to 1 KB
69-
* quartes the inner-loop work on long probes at no measurable
70-
* accuracy cost.
68+
* doesn't change the argmax, so capping the scan bounds the
69+
* inner-loop work on long probes at no measurable accuracy cost.
7170
*/
7271
private static final int MAX_PROBE_BYTES = 16 * 1024;
7372

@@ -216,8 +215,8 @@ private static Map<String, Cohort> buildCohortTable() {
216215
/**
217216
* Bigram-major int8 logP layout. Quantized at load time via
218217
* per-class scale {@code scale[c] = maxAbs(class c's logP column) / 127}.
219-
* In-memory footprint: {@code 65_536 × numClasses} bytes ≈ 2 MB for
220-
* 32 classes, 4× smaller than float32. The hot-loop accumulates
218+
* In-memory footprint: {@code 65_536 × numClasses} bytes ≈ 2.1 MB for
219+
* 34 classes, 4× smaller than float32. The hot-loop accumulates
221220
* raw int8 products and applies dequantization once at the end of
222221
* the probe, CharSoup-style.
223222
*/
@@ -309,9 +308,28 @@ public NaiveBayesBigramEncodingDetector(InputStream modelStream) throws IOExcept
309308
for (int bg = 0; bg < BIGRAM_SPACE; bg++) {
310309
logP8[bg * numClasses + c] = u;
311310
}
312-
// Overwrite with trained pairs.
311+
// Overwrite with trained pairs. Bigram ids are sorted ascending and
312+
// stored as varint deltas (LEB128) from the previous id.
313+
int bigram = 0;
313314
for (int i = 0; i < vocabSize; i++) {
314-
int bigram = dis.readUnsignedShort();
315+
long delta = 0;
316+
int shift = 0;
317+
int b;
318+
do {
319+
if (shift >= 35) {
320+
throw new IOException(
321+
"Malformed varint in bigram-id deltas (too long)");
322+
}
323+
b = dis.readUnsignedByte();
324+
delta |= (long) (b & 0x7F) << shift;
325+
shift += 7;
326+
} while ((b & 0x80) != 0);
327+
long next = bigram + delta;
328+
if (next < 0 || next >= BIGRAM_SPACE) {
329+
throw new IOException("Bigram id out of range: " + next
330+
+ " (expected [0, " + BIGRAM_SPACE + "))");
331+
}
332+
bigram = (int) next;
315333
byte q = dis.readByte();
316334
logP8[bigram * numClasses + c] = q;
317335
}
@@ -521,6 +539,7 @@ public ScoreResult scoreClassesAndCount(byte[] probe) {
521539
// diagnostic path.
522540
double[] score = new double[numClasses];
523541
double[] contributions = new double[numClasses];
542+
double[] bestPerCohort = new double[Cohort.values().length];
524543
int hashCap = counts.capacity();
525544
for (int slot = 0; slot < hashCap; slot++) {
526545
int bigram = counts.keyAt(slot);
@@ -549,6 +568,15 @@ public ScoreResult scoreClassesAndCount(byte[] probe) {
549568
// cohort, so the cap engages on cross-cohort gaps that a
550569
// max-vs-overall-runner-up cap missed when multiple classes
551570
// in top-1's cohort sat close together.
571+
//
572+
// Single per-class pass computes the contributions, the running
573+
// max/topClass, AND the best contribution per cohort; bestCrossCohort
574+
// then reduces over the (few) cohorts instead of a second full
575+
// per-class pass, and the clip is fused into the accumulate.
576+
// Bit-identical to the prior four-pass form: max/cross-cohort are exact
577+
// (comparisons over the same value set), and the contribution formula
578+
// and score[] accumulation order are unchanged.
579+
java.util.Arrays.fill(bestPerCohort, Double.NEGATIVE_INFINITY);
552580
int topClass = -1;
553581
double max = Double.NEGATIVE_INFINITY;
554582
for (int c = 0; c < numClasses; c++) {
@@ -558,25 +586,27 @@ public ScoreResult scoreClassesAndCount(byte[] probe) {
558586
max = contrib;
559587
topClass = c;
560588
}
589+
int co = cohorts[c].ordinal();
590+
if (contrib > bestPerCohort[co]) {
591+
bestPerCohort[co] = contrib;
592+
}
561593
}
562-
Cohort topCohort = cohorts[topClass];
594+
int topCohort = cohorts[topClass].ordinal();
563595
double bestCrossCohort = Double.NEGATIVE_INFINITY;
564-
for (int c = 0; c < numClasses; c++) {
565-
if (cohorts[c] != topCohort && contributions[c] > bestCrossCohort) {
566-
bestCrossCohort = contributions[c];
596+
for (int k = 0; k < bestPerCohort.length; k++) {
597+
if (k != topCohort && bestPerCohort[k] > bestCrossCohort) {
598+
bestCrossCohort = bestPerCohort[k];
567599
}
568600
}
569601
// bestCrossCohort is always finite here: load requires >=2 cohorts.
570602
double capValue = bestCrossCohort + CAP_PER_BIGRAM_NATS;
571-
if (max > capValue) {
572-
for (int c = 0; c < numClasses; c++) {
573-
if (contributions[c] > capValue) {
574-
contributions[c] = capValue;
575-
}
576-
}
577-
}
603+
boolean clip = max > capValue;
578604
for (int c = 0; c < numClasses; c++) {
579-
score[c] += contributions[c];
605+
double v = contributions[c];
606+
if (clip && v > capValue) {
607+
v = capValue;
608+
}
609+
score[c] += v;
580610
}
581611
}
582612
return new ScoreResult(score, scored, total);

0 commit comments

Comments
 (0)