Skip to content

Commit a2250af

Browse files
committed
docs
1 parent 770af54 commit a2250af

3 files changed

Lines changed: 137 additions & 2 deletions

File tree

docs/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
** xref:pipes/parse-modes.adoc[Parse Modes]
3131
** xref:pipes/unpack-config.adoc[Extracting Embedded Bytes]
3232
** xref:pipes/timeouts.adoc[Timeouts]
33+
** xref:pipes/cpu-sizing.adoc[Forked-JVM CPU Sizing]
3334
* xref:configuration/index.adoc[Configuration]
3435
** xref:configuration/parsers/pdf-parser.adoc[PDF Parser]
3536
** xref:configuration/parsers/tesseract-ocr-parser.adoc[Tesseract OCR]

docs/modules/ROOT/pages/pipes/configuration.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ how many forked JVMs to run, timeouts, memory management, and parse behavior.
4242

4343
|`numClients`
4444
|`4`
45-
|Number of parallel forked JVM processes. Each processes one document at a time.
45+
|Number of parallel forked JVM processes. Each processes one document at a time. See xref:pipes/cpu-sizing.adoc[Forked-JVM CPU Sizing] for guidance on choosing this value relative to host CPU count.
4646

4747
|`forkedJvmArgs`
4848
|`[]`
49-
|JVM arguments for forked processes (e.g., `["-Xmx512m", "-Xms256m"]`).
49+
|JVM arguments for forked processes (e.g., `["-Xmx512m", "-Xms256m"]`). When `numClients > 1`, Tika auto-injects `-XX:ActiveProcessorCount` to right-size each fork's GC and JIT thread pools unless you provide your own; see xref:pipes/cpu-sizing.adoc[Forked-JVM CPU Sizing].
5050

5151
|`javaPath`
5252
|`java`
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
= Forked-JVM CPU Sizing
2+
3+
Tika Pipes runs multiple forked JVMs in per-client mode (one per `numClients`).
4+
Each JVM independently sizes its garbage collector, JIT compiler, and common
5+
`ForkJoinPool` based on the host CPU count. Without intervention, this causes
6+
thread-pool blowup at high `numClients`: e.g., 4 forks on a 16-core host
7+
default to ~16 GC threads × 4 = ~64 GC threads, all competing for the same 16
8+
cores.
9+
10+
To fix this, Tika Pipes auto-injects `-XX:ActiveProcessorCount` into each
11+
forked JVM's command line, sizing each fork's view of the CPU count to a fair
12+
slice of the host. This is on by default in per-client mode (`numClients > 1`)
13+
when the user has not already supplied `-XX:ActiveProcessorCount` in
14+
`forkedJvmArgs`.
15+
16+
== Mental model
17+
18+
----
19+
pod_cpus = parent_overhead (≈ 2) + numClients × per_fork_slice
20+
----
21+
22+
Where `per_fork_slice ≥ 2`:
23+
24+
* 1 CPU for the parser thread
25+
* 1 CPU for everything else the JVM does (GC concurrent worker, JIT,
26+
protocol heartbeat, socket I/O thread)
27+
28+
The parent JVM (the one running `tika-async-cli` / `tika-app -a`) is light
29+
on CPU — it just serializes requests, deserializes responses, and runs
30+
the heartbeat — but it must not be CPU-starved. A starved parent shows up
31+
as pathological tail latency on small operations like `socket.write()`,
32+
because the calling thread gets preempted between clock reads. We reserve
33+
2 cores for the parent by default.
34+
35+
== Formula
36+
37+
[source]
38+
----
39+
slice = (hostCores - PARENT_RESERVED_CORES) / numClients
40+
41+
PARENT_RESERVED_CORES = 2
42+
MIN_AUTO_CAP_SLICE = 2
43+
----
44+
45+
If `slice ≥ 2`, Tika injects `-XX:ActiveProcessorCount=<slice>` into each
46+
forked JVM. If `slice < 2`, the auto-cap is *skipped* and a `WARN` is
47+
logged advising the operator to lower `numClients`. Skipping is intentional:
48+
at `slice=1` the fork's only CPU is fully consumed by parsing, so its
49+
socket-reader thread cannot run and the parent's writes block on
50+
receiver-side back-pressure — measurably worse than no cap at all.
51+
52+
== Recommended sizing
53+
54+
For typical cloud-VM core counts:
55+
56+
[cols="1,1,1,3"]
57+
|===
58+
|hostCores |numClients |slice |Notes
59+
60+
|2 |1 |n/a |Tight; auto-cap not applied (single fork). Acceptable for low throughput.
61+
|4 |1 |n/a |Comfortable single-fork deployment.
62+
|4 |2 |1 → skipped |Auto-cap declines; consider `numClients=1`.
63+
|8 |1 |n/a |Lots of headroom; single-fork lifecycle isolation is fine.
64+
|8 |3 |2 |Sweet spot for medium pods.
65+
|16 |4 |3 |Sweet spot for 16-core hosts. Measured winner in benchmarks.
66+
|16 |6 |2 |Higher concurrency; tighter per-fork breathing room.
67+
|16 |8 |1 → skipped |Doesn't fit 16 cores. Keep at 4 or 6.
68+
|32 |8 |3 |Same shape as 16/4.
69+
|===
70+
71+
The general rule is: pick the largest `numClients` that satisfies
72+
`numClients × 2 + 2 ≤ hostCores`. Beyond that point, adding workers
73+
starts hurting throughput.
74+
75+
== Diagnostics
76+
77+
Every `PipesParser` startup emits a one-shot summary line on its main
78+
logger so operators can see what was decided:
79+
80+
[source]
81+
----
82+
INFO pipes-cpu-sizing: hostCores=16, numClients=4, parentReserved=2, autoCap=slice=3
83+
----
84+
85+
The `autoCap` field is one of:
86+
87+
* `slice=N` — the auto-cap fired; each fork sees N CPUs.
88+
* `skipped (slice<2)` — over-provisioned; operator should reduce `numClients`.
89+
* `n/a (single fork; not capped)` — `numClients=1`; fork sees the whole host.
90+
* `user-set in forkedJvmArgs` — operator set `-XX:ActiveProcessorCount` themselves.
91+
92+
Two `WARN`-level messages call out clearly-bad provisioning:
93+
94+
* `hostCores < 2` — the host has no room for the parser plus background JVM threads.
95+
* `numClients × 2 + 2 > hostCores` — the host is too small for the requested concurrency.
96+
97+
`grep pipes-cpu-sizing` on the parent's logs surfaces all sizing-related output.
98+
99+
== Disabling or overriding
100+
101+
If you want to manage `ActiveProcessorCount` yourself (e.g., to allocate a
102+
different slice based on workload knowledge), just include it in your config:
103+
104+
[source,json]
105+
----
106+
"pipes": {
107+
"numClients": 4,
108+
"forkedJvmArgs": ["-Xmx512m", "-XX:ActiveProcessorCount=4"]
109+
}
110+
----
111+
112+
When Tika sees an explicit `-XX:ActiveProcessorCount` in `forkedJvmArgs`, it
113+
respects your value and skips the auto-injection — the sizing summary will
114+
report `autoCap=user-set in forkedJvmArgs`.
115+
116+
== Container & cgroup behavior
117+
118+
The formula uses `Runtime.availableProcessors()` for the host CPU count,
119+
which on JDK 17+ honors cgroup CPU limits. So in Kubernetes:
120+
121+
* If a pod has `resources.limits.cpu` set, the JVM sees that limit and the
122+
formula sizes accordingly.
123+
* If a pod runs without an explicit `limits.cpu`, the JVM sees the *node's*
124+
full CPU count, which may not match what the pod can actually use. **Always
125+
set explicit CPU limits on pipes pods.**
126+
127+
== Shared-server mode
128+
129+
This document only covers per-client (forked-JVM) mode, which is the
130+
default. In shared-server mode (`useSharedServer=true`) all clients use a
131+
single forked JVM, so the multi-process thread-blowup problem doesn't
132+
apply and the auto-cap is not applied. See
133+
xref:pipes/shared-server-mode.adoc[Shared Server Mode] for that mode's
134+
trade-offs.

0 commit comments

Comments
 (0)