Skip to content

Commit 1c9e5de

Browse files
authored
Merge pull request #445 from Red5/perf/rtmp-thread-oversubscription
Perf/rtmp thread oversubscription
2 parents 48056c2 + 3e13525 commit 1c9e5de

13 files changed

Lines changed: 181 additions & 25 deletions

File tree

RTMP_2CORE_TUNING_SUMMARY.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Red5 Low-Latency Tuning on Constrained (2-Core) Deployments — Executive Summary
2+
3+
## Situation
4+
5+
A prior optimization pass (`perf/rtmp-ingest-cpu`, merged as #444) reduced RTMP relay **average CPU ~12%** and **allocation ~39%**. QA/DevOps reported the build felt **no smoother than 2.0.34** on **2-core, CPU-limited container** deployments, where the metric that matters is **latency / smoothness (jitter, glitches)**.
6+
7+
## Key Finding
8+
9+
The earlier work optimized the wrong axis for this symptom. **Average CPU and garbage are not what cause streaming jitter on a CPU-capped container.** Jitter there is driven by *tail* events — CPU-quota throttling, thread over-subscription, and GC scheduling — none of which changed between 2.0.34 and the new build. The improvements were real but moved the median (p50), not the p99 that QA observes.
10+
11+
Three drivers were identified and measured (Docker, cgroup v2, 2 cores, 15 publishers + 30 subscribers):
12+
13+
| Driver | Evidence | Status |
14+
|---|---|---|
15+
| **Thread over-subscription** | One dedicated platform thread per connection (60 connections → 132 OS threads) plus a soft thread leak | **Fixed** — moved to virtual threads (132 → 69 OS threads, leak gone) |
16+
| **Wrong GC for few cores** | Legacy ZGC's always-on concurrent threads steal from the 2 app cores | **Fixed** — GC now selected by core count |
17+
| **CPU-quota (CFS) throttling** | A hard fractional CPU quota freezes all threads when the per-period budget is spent | **Mitigated + deployment guidance** |
18+
| **JVM core mis-detection** | CPU *requests*/shares without a hard *limit* make the JVM size every pool for host cores | **Deployment guidance** |
19+
20+
## What Changed (branch `perf/rtmp-thread-oversubscription`)
21+
22+
1. **Connection receive threads → virtual threads.** Eliminated one platform thread per connection and a never-shutdown executor leak. Ordering preserved. *Result: ~48% fewer OS threads under load.*
23+
2. **Resource-aware GC selection** in `red5.sh` (≤4 cores → ParallelGC, else G1). *Result: ~2.3× less quota throttling than ZGC on 2 cores, ~7 ms worst-case pause.*
24+
3. **Packaging fixes** so the distribution boots from scratch: restored `jcodec` + `commons-io` (dropped by a wildcard dependency exclusion) and added `json-smart` for the chat demo webapp.
25+
26+
### Measured impact (2-core container, identical 60-connection load)
27+
28+
| Metric | Before (2.0.34) | After |
29+
|---|---|---|
30+
| OS threads | 132 | **69** |
31+
| Per-connection platform threads | 60 | **0** |
32+
| GC quota-throttled time / 30 s | 617 ms (ZGC) | **266 ms** (Parallel) |
33+
| Worst GC pause | n/a (concurrent) | **6.8 ms** |
34+
| CPU-quota throttling with whole-core pinning | n/a | **0 ms** |
35+
36+
> Honest caveat: the thread fix reduces memory and scheduling overhead but **does not by itself remove CPU-quota throttling** — throttling is bound by total CPU demand. The largest jitter reductions on a capped container come from the **GC change** and, above all, **how CPU is allocated** (see below).
37+
38+
## Recommended Startup Settings
39+
40+
The startup script now **auto-selects the GC by detected core count**, so most deployments need no GC flags. The settings below cover the cases that still matter.
41+
42+
### Without Docker (bare metal / VM)
43+
44+
`red5.sh` detects cores via `nproc` and configures itself:
45+
46+
- **2-core VM:** automatically uses `ParallelGC` — no action needed.
47+
- **Larger server:** automatically uses `G1` with a 200 ms pause goal.
48+
49+
Adjust only the heap for the box, via the `JVM_OPTS` environment variable (overrides the script default):
50+
51+
```bash
52+
# Example: 2-core VM, give the relay a 2 GB heap, keep the auto-selected ParallelGC
53+
export JVM_OPTS="-XX:+UseParallelGC -Xms512m -Xmx2g -XX:ReservedCodeCacheSize=32m"
54+
./red5.sh
55+
```
56+
57+
If the host is large but Red5 is meant to use only some cores, pin it and tell the JVM:
58+
59+
```bash
60+
taskset -c 0,1 env JVM_OPTS="-XX:+UseParallelGC -XX:ActiveProcessorCount=2 -Xms512m -Xmx2g" ./red5.sh
61+
```
62+
63+
### With Docker
64+
65+
**Prefer whole-core pinning over a fractional quota** — it eliminates CPU-quota throttling entirely (measured 0 ms vs ~300 ms / 30 s):
66+
67+
```bash
68+
# Recommended: pin to whole cores (no CFS quota → no throttling stalls)
69+
docker run -d --cpuset-cpus="0,1" --memory=2g \
70+
-p 1935:1935 -p 5080:5080 \
71+
mondain/red5:latest
72+
# red5.sh sees 2 cores and selects ParallelGC automatically.
73+
```
74+
75+
If you must use a fractional quota (`--cpus`), make the JVM size its pools to the limit and expect some throttling:
76+
77+
```bash
78+
docker run -d --cpus=2 --memory=2g \
79+
-e JVM_OPTS="-XX:+UseParallelGC -XX:ActiveProcessorCount=2 -Xms512m -Xmx2g -XX:ReservedCodeCacheSize=32m" \
80+
-p 1935:1935 -p 5080:5080 \
81+
mondain/red5:latest
82+
```
83+
84+
### With Kubernetes
85+
86+
- Use **integer CPU limits** (e.g. `limits.cpu: "2"`) and enable the **static CPU Manager policy** (`--cpu-manager-policy=static`) so pods get exclusive whole cores — this is the k8s equivalent of `--cpuset-cpus` and avoids quota throttling.
87+
- Avoid setting only `requests.cpu` with no `limits.cpu`: the JVM then reads the **host** core count and over-sizes GC, JIT, and thread pools.
88+
- If fractional limits are unavoidable, add `-XX:ActiveProcessorCount=<limit>` via `JVM_OPTS`.
89+
90+
```yaml
91+
resources:
92+
requests: { cpu: "2", memory: "2Gi" }
93+
limits: { cpu: "2", memory: "2Gi" } # integer + static CPU manager → exclusive cores
94+
```
95+
96+
## Bottom Line
97+
98+
- The relay code and packaging are now correct and lighter on threads.
99+
- For **smoothness on capped deployments, the highest-leverage change is how CPU is allocated**: pin whole cores (cpuset / k8s static CPU manager) instead of a fractional quota, and ensure a hard limit so the JVM sizes itself correctly.
100+
- Validate with **p99 frame jitter and cgroup `cpu.stat` (`nr_throttled` / `throttled_usec`)** — not average CPU — comparing the same load against 2.0.34.

client/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<parent>
44
<groupId>org.red5</groupId>
55
<artifactId>red5-parent</artifactId>
6-
<version>2.0.37</version>
6+
<version>2.0.38</version>
77
</parent>
88
<modelVersion>4.0.0</modelVersion>
99
<artifactId>red5-client</artifactId>

client/src/main/java/org/red5/client/Red5Client.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public final class Red5Client {
1818
/**
1919
* Current server version with revision
2020
*/
21-
public static final String VERSION = "Red5 Client 2.0.37";
21+
public static final String VERSION = "Red5 Client 2.0.38";
2222

2323
/**
2424
* Create a new Red5Client object using the connection local to the current thread A bit of magic that lets you access the red5 scope

common/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<parent>
44
<groupId>org.red5</groupId>
55
<artifactId>red5-parent</artifactId>
6-
<version>2.0.37</version>
6+
<version>2.0.38</version>
77
</parent>
88
<modelVersion>4.0.0</modelVersion>
99
<artifactId>red5-server-common</artifactId>

common/src/main/java/org/red5/server/api/Red5.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ public final class Red5 {
5757
/**
5858
* Server version with revision
5959
*/
60-
public static final String VERSION = "Red5 Server 2.0.37";
60+
public static final String VERSION = "Red5 Server 2.0.38";
6161

6262
/**
6363
* Server version for fmsVer requests
6464
*/
65-
public static final String FMS_VERSION = "RED5/2,0,37,0";
65+
public static final String FMS_VERSION = "RED5/2,0,38,0";
6666

6767
/**
6868
* Server capabilities

common/src/main/java/org/red5/server/net/rtmp/RTMPConnection.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,16 @@ public abstract class RTMPConnection extends BaseConnection implements IStreamCa
379379
protected ScheduledFuture<?> keepAliveTask;
380380

381381
/**
382-
* Executor for received RTMP messages.
383-
*/
384-
protected transient ExecutorService receivedPacketExecutor = Executors.newSingleThreadExecutor();
382+
* Executor for received RTMP messages. Uses a virtual-thread-per-task executor: exactly one
383+
* long-lived loop task is submitted per connection (guarded by {@link #receivedPacketFuture}),
384+
* so the previous {@code newSingleThreadExecutor()} pinned one platform thread per connection
385+
* for the life of the connection and was never shut down (a soft platform-thread leak relying
386+
* on GC finalization). The loop blocks in {@code receivedPacketQueue.poll(timeout)}, which is
387+
* virtual-thread-aware, so an idle connection unmounts from its carrier and holds no platform
388+
* thread; the virtual thread terminates when the loop exits on close. Per-connection ordering
389+
* is preserved because there is still a single serial loop per connection.
390+
*/
391+
protected transient ExecutorService receivedPacketExecutor = Executors.newVirtualThreadPerTaskExecutor();
385392

386393
/**
387394
* Future which takes packets from the queue and passes them to the handler.

io/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<parent>
44
<groupId>org.red5</groupId>
55
<artifactId>red5-parent</artifactId>
6-
<version>2.0.37</version>
6+
<version>2.0.38</version>
77
</parent>
88
<modelVersion>4.0.0</modelVersion>
99
<artifactId>red5-io</artifactId>

pom.xml

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
<name>Red5</name>
2525
<description>The Red5 server</description>
2626
<groupId>org.red5</groupId>
27-
<version>2.0.37</version>
27+
<version>2.0.38</version>
2828
<url>https://github.com/Red5/red5-server</url>
2929
<inceptionYear>2005</inceptionYear>
3030
<organization>
@@ -119,6 +119,7 @@
119119
<cglib.version>3.2.7</cglib.version>
120120
<xerces.version>2.12.1</xerces.version>
121121
<gson.version>2.13.2</gson.version>
122+
<json-smart.version>2.5.2</json-smart.version>
122123
</properties>
123124
<modules>
124125
<module>io</module>
@@ -386,12 +387,15 @@
386387
<groupId>org.red5</groupId>
387388
<artifactId>red5-io</artifactId>
388389
<version>${red5-io.version}</version>
389-
<exclusions>
390-
<exclusion>
391-
<artifactId>*</artifactId>
392-
<groupId>*</groupId>
393-
</exclusion>
394-
</exclusions>
390+
<!--
391+
No wildcard exclusion here. A previous *:* exclusion stripped ALL of red5-io's
392+
transitive dependencies; most are shared with red5-server-common (and are version
393+
managed below, so they dedup harmlessly), but two are unique to red5-io and are
394+
required at runtime: org.jcodec:jcodec (MP4Reader / HVC1Box) and commons-io. The
395+
wildcard silently dropped both from the assembled lib/, so the server failed to
396+
boot with NoClassDefFoundError: org/jcodec/common/io/SeekableByteChannel once
397+
MP4Reader began touching jcodec classes.
398+
-->
395399
</dependency>
396400
<dependency>
397401
<groupId>org.red5</groupId>
@@ -518,6 +522,18 @@
518522
<artifactId>gson</artifactId>
519523
<version>${gson.version}</version>
520524
</dependency>
525+
<!--
526+
Required at runtime by the bundled chat demo webapp (org.red5.demos.chat.*, compiled
527+
against net.minidev.json). It was previously pulled via an undefined ${json-smart.version}
528+
property and silently dropped, leaving the chat webapp failing to deploy with
529+
NoClassDefFoundError: net/minidev/json/parser/ParseException. Managed here and bundled
530+
into lib/ via the server module so webapps see it on the shared classpath.
531+
-->
532+
<dependency>
533+
<groupId>net.minidev</groupId>
534+
<artifactId>json-smart</artifactId>
535+
<version>${json-smart.version}</version>
536+
</dependency>
521537
<dependency>
522538
<groupId>org.hamcrest</groupId>
523539
<artifactId>hamcrest</artifactId>

server/pom.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<parent>
44
<groupId>org.red5</groupId>
55
<artifactId>red5-parent</artifactId>
6-
<version>2.0.37</version>
6+
<version>2.0.38</version>
77
</parent>
88
<modelVersion>4.0.0</modelVersion>
99
<artifactId>red5-server</artifactId>
@@ -140,6 +140,11 @@
140140
<groupId>com.google.code.gson</groupId>
141141
<artifactId>gson</artifactId>
142142
</dependency>
143+
<!-- runtime dependency of the bundled chat demo webapp (net.minidev.json); see parent dependencyManagement -->
144+
<dependency>
145+
<groupId>net.minidev</groupId>
146+
<artifactId>json-smart</artifactId>
147+
</dependency>
143148
<dependency>
144149
<groupId>org.red5</groupId>
145150
<artifactId>red5-moq-pkgr</artifactId>

server/src/main/server/red5.sh

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,38 @@ esac
4545
echo "Running on " $OS
4646

4747
# JAVA options
48-
# ZGC collector https://wiki.openjdk.java.net/display/zgc
49-
# You can set JVM additional options here if you want
50-
if [ -z "$JVM_OPTS" ]; then
51-
JVM_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xms256m -Xmx1g -XX:InitialCodeCacheSize=8m -XX:MaxGCPauseMillis=500 -XX:ReservedCodeCacheSize=32m"
48+
# You can set JVM additional options here if you want; defining JVM_OPTS in the environment
49+
# overrides everything below.
50+
#
51+
# Container/CPU-limited note: thread pools (RTMP io threads, scheduler, GC workers, virtual-thread
52+
# carriers) are sized from Runtime.availableProcessors(). The JVM only derives a reduced core count
53+
# from a hard CPU *limit* (cgroup quota / cpuset); CPU *requests*/shares alone leave it reading the
54+
# host core count and oversizing every pool. On a CPU-capped deployment either set a hard limit or
55+
# pin the count explicitly, e.g. add: -XX:ActiveProcessorCount=2
56+
#
57+
# CFS throttling note: a hard CPU *quota* (docker --cpus / k8s fractional limit) freezes ALL threads
58+
# when the per-period quota is exhausted, which shows up as streaming jitter. Pinning to whole cores
59+
# instead (docker --cpuset-cpus / k8s static CPU manager with integer limits) removes quota
60+
# throttling entirely. Measured here: 15 publishers + 30 subscribers on ~2 cores produced ~300ms of
61+
# throttled (frozen) time per 30s under --cpus=2, and 0ms under --cpuset-cpus=0,1.
62+
#
63+
# GC selection (resource-aware). Measured under the same 2-core load (1g heap), CFS throttled time
64+
# per 30s and worst stop-the-world pause:
65+
# ZGC (old default) 617ms / 0ms STW G1 657ms / 9.8ms Parallel 266ms / 6.8ms Serial 156ms / 45ms
66+
# On few cores the always-on concurrent collectors (ZGC, G1) steal from the app and throttle most;
67+
# ParallelGC gives the best balance (low throttling, ~7ms max pause). On larger multi-core hosts with
68+
# bigger heaps G1 is the better general-purpose choice (Parallel/Serial full-GC pauses scale with the
69+
# heap). So pick by detected core count; nproc honors a hard cpu limit / cpuset (but not bare shares).
70+
if [ -z "$JVM_OPTS" ]; then
71+
CORES=$(nproc 2>/dev/null || echo 4)
72+
if [ "$CORES" -le 4 ]; then
73+
# CPU-limited / small container: low-overhead throughput collector, least CFS throttling
74+
GC_OPTS="-XX:+UseParallelGC"
75+
else
76+
# larger multi-core host: general-purpose low-pause collector
77+
GC_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
78+
fi
79+
JVM_OPTS="$GC_OPTS -Xms256m -Xmx1g -XX:InitialCodeCacheSize=8m -XX:ReservedCodeCacheSize=32m"
5280
fi
5381
# Set up security options
5482
SECURITY_OPTS="-Djava.security.debug=failure"
@@ -71,4 +99,4 @@ export RED5_CLASSPATH="${RED5_HOME}/red5-service.jar${P}${RED5_HOME}/conf${P}${C
7199

72100
# start Red5
73101
echo "Starting Red5"
74-
exec "$JAVA" -Dred5.root="${RED5_HOME}" $JAVA_OPTS -cp "${RED5_CLASSPATH}" -noverify "$RED5_MAINCLASS" $RED5_OPTS
102+
exec "$JAVA" -Dred5.root="${RED5_HOME}" $JAVA_OPTS -cp "${RED5_CLASSPATH}" "$RED5_MAINCLASS" $RED5_OPTS

0 commit comments

Comments
 (0)