TurboVNC — Raise Tight Encoding Thread Cap from 4 to 32
A reproducible, hand-applicable patch that makes the TurboVNC server's
multithreaded Tight encoder support up to 32 threads (was hard-capped at
4), with per-frame dynamic scaling and no zlib stream corruption.
- Base: TurboVNC
3.3.1 (github.com/TurboVNC/turbovnc)
- Files changed: 7 (6 server/build, 1 viewer)
- Lines: ~90 changed
- Tested: builds clean on Rocky 8 / RHEL 8; ran at a real 32 threads
(2560x1440, 46k+ Tight rectangles, 936 frames) — 0 decode errors,
0 AddressSanitizer errors, clean across client reconnects and tiny
windows.
1. Why the limit was 4 (read this first)
The Tight wire protocol defines exactly 4 zlib stream IDs. The stock
server keeps 4 shared z_streams per client and hands each encoder thread a
disjoint subset — so with >4 threads, multiple threads would deflate() the
same z_stream concurrently → heap corruption → segfault.
This patch fixes that properly:
- Each thread gets its own 4
z_streams (threadparam.zs[4]) → no two
threads ever touch one z_stream.
- The 4 wire IDs are reused round-robin (
wireId = i % 4); every thread
resets its wire stream at the start of its strip each frame, so the
client's zlib decoder re-aligns at every thread boundary.
nt <= 4 behaves byte-identically to stock — all new risk is confined
to the nt > 4 path.
- The viewer has a latent bug (
Inflater.end() instead of .reset()) that is
dead code in stock but fatal once the server emits reset bits — fixed here
(1 line).
Trade-off: with nt > 4, cross-frame zlib history is dropped (each
strip is recompressed fresh each frame). Negligible for motion/3D content;
irrelevant in practice because static content produces small rectangles that
never trigger the nt > 4 path.
2. Fast path — apply the patch file
If you have the turbovnc-mt32.patch file from this directory:
git clone https://github.com/TurboVNC/turbovnc.git
cd turbovnc
git checkout 3.3.1 # match the base version
git apply /path/to/turbovnc-mt32.patch
Otherwise apply the 7 files by hand using Section 3.
3. The changes — file by file (before / after)
3.1 unix/Xvnc/programs/Xserver/hw/vnc/rfb.h
Change A — raise the cap (line ~90):
/* BEFORE */
#define MAX_ENCODING_THREADS 4
/* AFTER */
#define MAX_ENCODING_THREADS 32
Change B — add a client-generation field to rfbClientRec (line ~417):
/* BEFORE */
/* tight encoding -- preserve zlib streams' state for each client */
z_stream zsStruct[4];
/* AFTER */
/* tight encoding -- preserve zlib streams' state for each client */
unsigned int generation; /* monotonic client-identity token */
z_stream zsStruct[4];
3.2 unix/Xvnc/programs/Xserver/hw/vnc/rfbserver.c
Change A — add a global counter (line ~95):
/* BEFORE */
int rfbNumThreads = 0;
/* AFTER */
int rfbNumThreads = 0;
unsigned int rfbClientGeneration = 0; /* bumped per new client */
Change B — stamp each new client (in rfbNewClient, line ~406):
/* BEFORE */
cl->id = rfbClientNumber++;
if (rfbClientNumber == 0) rfbClientNumber = 1;
/* AFTER */
cl->id = rfbClientNumber++;
if (rfbClientNumber == 0) rfbClientNumber = 1;
cl->generation = ++rfbClientGeneration;
Change C — raise the default thread count (line ~533):
/* BEFORE */
else if (rfbNumThreads < 1) rfbNumThreads = min(np, 4);
/* AFTER */
else if (rfbNumThreads < 1) rfbNumThreads = min(np, MAX_ENCODING_THREADS);
The if (rfbNumThreads > np) clamp just below is unchanged — the server
still never runs more threads than CPU cores.
3.3 unix/Xvnc/programs/Xserver/hw/vnc/tight.c
Change A — fix the thnd[] initializer (line ~131):
/* BEFORE */
static pthread_t thnd[MAX_ENCODING_THREADS] = { 0, 0, 0, 0 };
/* AFTER */
static pthread_t thnd[MAX_ENCODING_THREADS] = { 0 };
Change B — add per-thread zlib state to threadparam (line ~147):
/* BEFORE */
int streamId, baseStreamId, nStreams;
pthread_mutex_t ready, done;
/* AFTER */
int streamId, baseStreamId, nStreams;
/* per-thread zlib state -> nt>4 path. each thread deflates own zs[] only. */
z_stream zs[4];
Bool zsActive[4];
int zsLevel[4];
Bool resetStream[4]; /* next subrect on wire id k must carry reset bit */
unsigned int clientGen; /* generation of client these zs belong to */
pthread_mutex_t ready, done;
Change C — comment in InitThreads (line ~294):
/* BEFORE */
memset(tparam, 0, sizeof(threadparam) * MAX_ENCODING_THREADS);
tparam[0].ublen = &ublen;
/* AFTER */
memset(tparam, 0, sizeof(threadparam) * MAX_ENCODING_THREADS);
/* zs[] left zeroed -> lazy deflateInit2 in CompressData on first use. */
tparam[0].ublen = &ublen;
Change D — clean up per-thread streams in ShutdownTightThreads (line ~341):
/* BEFORE */
for (i = 0; i < rfbNumThreads; i++) {
free(tparam[i].tightAfterBuf);
free(tparam[i].tightBeforeBuf);
if (i != 0) free(tparam[i].updateBuf);
if (tparam[i].j) tjDestroy(tparam[i].j);
if (!REGION_NAR(&tparam[i].losslessRegion))
/* AFTER */
for (i = 0; i < rfbNumThreads; i++) {
int k;
free(tparam[i].tightAfterBuf);
free(tparam[i].tightBeforeBuf);
if (i != 0) free(tparam[i].updateBuf);
if (tparam[i].j) tjDestroy(tparam[i].j);
/* deflateEnd active per-thread streams before memset -> no zlib heap leak */
for (k = 0; k < 4; k++) {
if (tparam[i].zsActive[k]) {
deflateEnd(&tparam[i].zs[k]);
tparam[i].zsActive[k] = FALSE;
}
}
if (!REGION_NAR(&tparam[i].losslessRegion))
Change E — zero-height strip guard (in rfbSendRectEncodingTight, line ~408):
/* BEFORE */
nt = min(rfbNumThreads, w * h / MAXRECTSIZE);
if (nt < 1) nt = 1;
/* AFTER */
nt = min(rfbNumThreads, w * h / MAXRECTSIZE);
nt = min(nt, h); /* never a 0-height strip */
if (nt < 1) nt = 1;
Change F — thread-to-stream assignment (line ~423). Replace the whole
if (i < 4) { ... } block:
/* BEFORE */
/* Tight encoding has only a limited number of zlib streams (4). The
streams must all be left open as long as the client is connected, or
performance suffers. Thus, multiple threads can't use the same zlib
stream. We divide the pool of 4 evenly among the available threads (up
to the first 4 threads), and if each thread has more than one stream, it
cycles between them in a round-robin fashion. */
if (i < 4) {
int n = min(nt, 4);
tparam[i].baseStreamId = 4 / n * i;
if (i == n - 1) tparam[i].nStreams = 4 - tparam[i].baseStreamId;
else tparam[i].nStreams = 4 / n;
tparam[i].streamId = tparam[i].baseStreamId;
}
/* AFTER */
tparam[i].resetStream[0] = tparam[i].resetStream[1] =
tparam[i].resetStream[2] = tparam[i].resetStream[3] = FALSE;
/* Tight has only 4 wire zlib stream ids (0..3). nt<=4: divide the 4 ids
disjointly, streams persist cross-frame, no resets -> wire output
byte-identical to stock. nt>4: pin thread i to wire id i%4, deflate
through its own t->zs, reset that id before the thread's first zlib
subrect so the client realigns its inflate context. */
if (nt <= 4) {
if (i < 4) {
int n = min(nt, 4);
tparam[i].baseStreamId = 4 / n * i;
if (i == n - 1) tparam[i].nStreams = 4 - tparam[i].baseStreamId;
else tparam[i].nStreams = 4 / n;
tparam[i].streamId = tparam[i].baseStreamId;
}
} else {
int wireId = i % 4;
tparam[i].baseStreamId = wireId; /* single wire id */
tparam[i].nStreams = 0; /* no round-robin */
tparam[i].streamId = wireId;
/* >4 threads share 4 wire ids -> every strip resets its stream each
frame so client inflater realigns; no cross-frame history kept. */
tparam[i].resetStream[wireId] = TRUE;
}
Change G — control byte in SendMonoRect (line ~978):
/* BEFORE */
else
t->updateBuf[(*t->ublen)++] = (streamId | rfbTightExplicitFilter) << 4;
t->updateBuf[(*t->ublen)++] = rfbTightFilterPalette;
t->updateBuf[(*t->ublen)++] = 1;
/* AFTER */
else
t->updateBuf[(*t->ublen)++] =
(char)(((streamId | rfbTightExplicitFilter) << 4) |
(t->resetStream[streamId] ? (1 << streamId) : 0));
t->updateBuf[(*t->ublen)++] = rfbTightFilterPalette;
t->updateBuf[(*t->ublen)++] = 1;
Change H — control byte in SendIndexedRect (line ~1046):
/* BEFORE */
else
t->updateBuf[(*t->ublen)++] = (streamId | rfbTightExplicitFilter) << 4;
t->updateBuf[(*t->ublen)++] = rfbTightFilterPalette;
t->updateBuf[(*t->ublen)++] = (char)(t->paletteNumColors - 1);
/* AFTER */
else
t->updateBuf[(*t->ublen)++] =
(char)(((streamId | rfbTightExplicitFilter) << 4) |
(t->resetStream[streamId] ? (1 << streamId) : 0));
t->updateBuf[(*t->ublen)++] = rfbTightFilterPalette;
t->updateBuf[(*t->ublen)++] = (char)(t->paletteNumColors - 1);
Change I — control byte in SendFullColorRect (line ~1111):
/* BEFORE */
else
t->updateBuf[(*t->ublen)++] = streamId << 4;
t->bytessent++;
/* AFTER */
else
t->updateBuf[(*t->ublen)++] =
(char)((streamId << 4) |
(t->resetStream[streamId] ? (1 << streamId) : 0));
t->bytessent++;
Change J — CompressData (line ~1126). This function changes in three
spots. Here is the full function, before and after:
/* BEFORE */
static Bool CompressData(threadparam *t, int streamId, int dataLen,
int zlibLevel, int zlibStrategy)
{
z_streamp pz;
int err;
rfbClientPtr cl = t->cl;
if (dataLen < TIGHT_MIN_TO_COMPRESS) {
memcpy(&t->updateBuf[*t->ublen], t->tightBeforeBuf, dataLen);
(*t->ublen) += dataLen;
t->bytessent += dataLen;
return TRUE;
}
if (zlibLevel == 0 && cl->enableTightWithoutZlib)
return SendCompressedData(t, t->tightBeforeBuf, dataLen);
pz = &cl->zsStruct[streamId];
/* Initialize compression stream if needed. */
if (!cl->zsActive[streamId]) {
pz->zalloc = Z_NULL;
pz->zfree = Z_NULL;
pz->opaque = Z_NULL;
err = deflateInit2(pz, zlibLevel, Z_DEFLATED, MAX_WBITS, MAX_MEM_LEVEL,
zlibStrategy);
if (err != Z_OK)
return FALSE;
cl->zsActive[streamId] = TRUE;
cl->zsLevel[streamId] = zlibLevel;
}
/* Prepare buffer pointers. */
pz->next_in = (Bytef *)t->tightBeforeBuf;
pz->avail_in = dataLen;
pz->next_out = (Bytef *)t->tightAfterBuf;
pz->avail_out = t->tightAfterBufSize;
/* Change compression parameters if needed. */
if (zlibLevel != cl->zsLevel[streamId]) {
if (deflateParams(pz, zlibLevel, zlibStrategy) != Z_OK)
return FALSE;
cl->zsLevel[streamId] = zlibLevel;
}
/* Actual compression. */
if (deflate(pz, Z_SYNC_FLUSH) != Z_OK || pz->avail_in != 0 ||
pz->avail_out == 0)
return FALSE;
return SendCompressedData(t, t->tightAfterBuf,
t->tightAfterBufSize - pz->avail_out);
}
/* AFTER */
static Bool CompressData(threadparam *t, int streamId, int dataLen,
int zlibLevel, int zlibStrategy)
{
z_streamp pz;
int err;
rfbClientPtr cl = t->cl;
if (dataLen < TIGHT_MIN_TO_COMPRESS) {
memcpy(&t->updateBuf[*t->ublen], t->tightBeforeBuf, dataLen);
(*t->ublen) += dataLen;
t->bytessent += dataLen;
return TRUE;
}
if (zlibLevel == 0 && cl->enableTightWithoutZlib)
return SendCompressedData(t, t->tightBeforeBuf, dataLen);
/* tparam[] is process-global -> a thread's zs may carry a prior client's
deflate history. on client change, end every active stream. */
if (t->clientGen != cl->generation) {
int s;
for (s = 0; s < 4; s++) {
if (t->zsActive[s]) {
deflateEnd(&t->zs[s]);
t->zsActive[s] = FALSE;
}
}
t->clientGen = cl->generation;
}
pz = &t->zs[streamId];
/* Initialize compression stream if needed. */
if (!t->zsActive[streamId]) {
pz->zalloc = Z_NULL;
pz->zfree = Z_NULL;
pz->opaque = Z_NULL;
err = deflateInit2(pz, zlibLevel, Z_DEFLATED, MAX_WBITS, MAX_MEM_LEVEL,
zlibStrategy);
if (err != Z_OK)
return FALSE;
t->zsActive[streamId] = TRUE;
t->zsLevel[streamId] = zlibLevel;
} else if (t->resetStream[streamId]) {
/* shared wire id -> restart server deflate history to match the
inflateReset the client does on the reset bit. */
if (deflateReset(pz) != Z_OK)
return FALSE;
}
/* Prepare buffer pointers. */
pz->next_in = (Bytef *)t->tightBeforeBuf;
pz->avail_in = dataLen;
pz->next_out = (Bytef *)t->tightAfterBuf;
pz->avail_out = t->tightAfterBufSize;
/* Change compression parameters if needed. */
if (zlibLevel != t->zsLevel[streamId]) {
if (deflateParams(pz, zlibLevel, zlibStrategy) != Z_OK)
return FALSE;
t->zsLevel[streamId] = zlibLevel;
}
/* Actual compression. */
if (deflate(pz, Z_SYNC_FLUSH) != Z_OK || pz->avail_in != 0 ||
pz->avail_out == 0)
return FALSE;
/* reset bit emitted in caller's control byte; fresh deflateInit2 above
realigned server+client streams -> clear so later subrects don't re-reset */
t->resetStream[streamId] = FALSE;
return SendCompressedData(t, t->tightAfterBuf,
t->tightAfterBufSize - pz->avail_out);
}
3.4 unix/Xvnc/programs/Xserver/hw/vnc/init.c — help text (line ~1900)
/* BEFORE */
ErrorF(" max. 4]\n");
/* AFTER */
ErrorF(" max. %d]\n", MAX_ENCODING_THREADS);
3.5 unix/Xvnc/programs/Xserver/Xvnc.man.in — man page (line ~373)
/* BEFORE */
Specify the number of threads to use with multithreaded Tight encoding. The
default is to use one thread per CPU core, up to a maximum of 4 (because using
more than 4 encoding threads breaks compatibility with viewers other than the
TurboVNC Viewer.) The server will not allow the thread count to exceed 4, nor
to exceed the number of CPU cores.
/* AFTER */
Specify the number of threads to use with multithreaded Tight encoding. The
default is to use one thread per CPU core, up to a maximum of 32. The server
will not allow the thread count to exceed 32, nor to exceed the number of CPU
cores. Using more than 4 encoding threads requires the TurboVNC Viewer; other
viewers support a maximum of 4 Tight zlib streams.
3.6 CMakeLists.txt — version marker (line ~9)
# BEFORE
set(VERSION 3.3.1)
set(DOCVERSION 3.3.1)
# AFTER
set(VERSION 3.3.1.mt32)
set(DOCVERSION 3.3.1.mt32)
Use a dot (3.3.1.mt32), not a dash — RPM rejects - in a version
string. This makes the rebuilt RPM identifiable (rpm -q turbovnc).
3.7 java/com/turbovnc/rfb/TightDecoder.java — viewer fix (line ~144)
Required. Without it the viewer crashes the moment the server emits a
stream-reset bit.
/* BEFORE */
for (int i = 0; i < 4; i++) {
if ((compCtl & 1) != 0)
inflater[i].end();
compCtl >>= 1;
}
/* AFTER */
for (int i = 0; i < 4; i++) {
if ((compCtl & 1) != 0)
inflater[i].reset();
compCtl >>= 1;
}
Inflater.end() permanently destroys the inflater (next use throws
IllegalStateException); .reset() is the correct flush. The rebuilt RPM
ships this viewer, so deploy the rebuilt VncViewer.jar wherever you run
the viewer.
4. Build & install the RPM (RHEL 8 / Rocky 8)
# --- build prerequisites ---
sudo dnf install -y gcc gcc-c++ make cmake git bison flex rpm-build python3 \
zlib-devel openssl-devel pam-devel pixman-devel \
libX11-devel libXext-devel libXfont2-devel libxkbfile-devel \
libxshmfence-devel libXdmcp-devel libXau-devel libdrm-devel \
xorg-x11-xtrans-devel xorg-x11-util-macros xorg-x11-proto-devel \
turbojpeg-devel java-17-openjdk-devel
# NOTE: turbojpeg-devel provides turbojpeg.h + libturbojpeg.
# JDK 17 is required (the bundled viewer needs JDK 16+).
# --- configure + build ---
cd turbovnc # the patched source tree
mkdir build && cd build
JAVA_HOME=/usr/lib/jvm/java-17 cmake -G"Unix Makefiles" \
-DCMAKE_BUILD_TYPE=Release \
-DTJPEG_INCLUDE_DIR=/usr/include \
-DTJPEG_LIBRARY=/usr/lib64/libturbojpeg.so \
-DJAVA_HOME=/usr/lib/jvm/java-17 ..
make -j$(nproc)
# --- package + install the RPM (the intended way) ---
make rpm # produces turbovnc-3.3.1.mt32.x86_64.rpm
sudo rpm -Uvh --force turbovnc-3.3.1.mt32.x86_64.rpm
Verify:
/opt/TurboVNC/bin/Xvnc -version # -> ...(Xvnc) ... v3.3.1.mt32...
5. Specifying the thread count in turbovncserver.conf
The TurboVNC server config file is /etc/turbovncserver.conf (system-wide)
or ~/.vnc/turbovncserver.conf (per-user). It is Perl. The variable
$serverArgs passes extra arguments straight to Xvnc.
Add this line:
# /etc/turbovncserver.conf (or ~/.vnc/turbovncserver.conf)
$serverArgs = "-nthreads 32";
Then start a session normally:
/opt/TurboVNC/bin/vncserver
Other ways to set it
| Method |
How |
| Config file |
$serverArgs = "-nthreads 32"; in turbovncserver.conf |
| Command line |
vncserver -nthreads 32 |
| Environment |
export TVNC_NTHREADS=32 before vncserver |
| Do nothing |
Default now auto-scales to min(CPU cores, 32) per frame |
Notes:
- Valid range:
1–32. The server still clamps to your CPU core count.
-nthreads is a hard ceiling. The encoder picks fewer threads per frame
for small screen updates (dynamic scaling) — small updates never spawn 32
threads, so there is no contention overhead.
- On your 80-core box,
-nthreads 32 runs a true 32. Realistic sweet spot is
~8–16; benchmark -nthreads 4 / 8 / 16 / 32 on your real workload and pick
the knee.
To confirm it took effect, check the session log (~/.vnc/*.log):
Using 32 threads for Tight encoding
6. What was tested
Built and run on Rocky 8 (Xvnc 3.3.1.mt32):
| Test |
Result |
| Clean Release build (full X server + Java viewer) |
pass, 0 warnings in changed files |
-nthreads 32, real 32 threads, 2560×1440, 40 s motion |
"Using 32 threads", 46k+ Tight rects, 0 decode errors |
AddressSanitizer build, -nthreads 32 under load |
0 ASan errors (no heap corruption / leak / UAF) |
-nthreads 1 / 4 / 8 / 16 / 24 / 32 |
all decode clean |
| Client disconnect + reconnect ×3 |
clean (per-thread generation guard) |
Tiny window 240×6, -nthreads 32 |
no crash, no SIGFPE (zero-height-strip guard) |
| Patched viewer end-to-end decode |
clean (reset-bit handshake works) |
Recommended on your real box: also run -nthreads 4/8/16/32 benchmarks
(TVNC_PROFILE=1 env logs encoder throughput) to find your knee.
TurboVNC — Raise Tight Encoding Thread Cap from 4 to 32
A reproducible, hand-applicable patch that makes the TurboVNC server's
multithreaded Tight encoder support up to 32 threads (was hard-capped at
4), with per-frame dynamic scaling and no zlib stream corruption.
3.3.1(github.com/TurboVNC/turbovnc)(
2560x1440, 46k+ Tight rectangles, 936 frames) — 0 decode errors,0 AddressSanitizer errors, clean across client reconnects and tiny
windows.
1. Why the limit was 4 (read this first)
The Tight wire protocol defines exactly 4 zlib stream IDs. The stock
server keeps 4 shared
z_streams per client and hands each encoder thread adisjoint subset — so with >4 threads, multiple threads would
deflate()thesame
z_streamconcurrently → heap corruption → segfault.This patch fixes that properly:
z_streams (threadparam.zs[4]) → no twothreads ever touch one
z_stream.wireId = i % 4); every threadresets its wire stream at the start of its strip each frame, so the
client's zlib decoder re-aligns at every thread boundary.
nt <= 4behaves byte-identically to stock — all new risk is confinedto the
nt > 4path.Inflater.end()instead of.reset()) that isdead code in stock but fatal once the server emits reset bits — fixed here
(1 line).
Trade-off: with
nt > 4, cross-frame zlib history is dropped (eachstrip is recompressed fresh each frame). Negligible for motion/3D content;
irrelevant in practice because static content produces small rectangles that
never trigger the
nt > 4path.2. Fast path — apply the patch file
If you have the
turbovnc-mt32.patchfile from this directory:Otherwise apply the 7 files by hand using Section 3.
3. The changes — file by file (before / after)
3.1
unix/Xvnc/programs/Xserver/hw/vnc/rfb.hChange A — raise the cap (line ~90):
Change B — add a client-generation field to
rfbClientRec(line ~417):3.2
unix/Xvnc/programs/Xserver/hw/vnc/rfbserver.cChange A — add a global counter (line ~95):
Change B — stamp each new client (in
rfbNewClient, line ~406):Change C — raise the default thread count (line ~533):
3.3
unix/Xvnc/programs/Xserver/hw/vnc/tight.cChange A — fix the
thnd[]initializer (line ~131):Change B — add per-thread zlib state to
threadparam(line ~147):Change C — comment in
InitThreads(line ~294):Change D — clean up per-thread streams in
ShutdownTightThreads(line ~341):Change E — zero-height strip guard (in
rfbSendRectEncodingTight, line ~408):Change F — thread-to-stream assignment (line ~423). Replace the whole
if (i < 4) { ... }block:Change G — control byte in
SendMonoRect(line ~978):Change H — control byte in
SendIndexedRect(line ~1046):Change I — control byte in
SendFullColorRect(line ~1111):Change J —
CompressData(line ~1126). This function changes in threespots. Here is the full function, before and after:
3.4
unix/Xvnc/programs/Xserver/hw/vnc/init.c— help text (line ~1900)3.5
unix/Xvnc/programs/Xserver/Xvnc.man.in— man page (line ~373)3.6
CMakeLists.txt— version marker (line ~9)3.7
java/com/turbovnc/rfb/TightDecoder.java— viewer fix (line ~144)Required. Without it the viewer crashes the moment the server emits a
stream-reset bit.
4. Build & install the RPM (RHEL 8 / Rocky 8)
Verify:
/opt/TurboVNC/bin/Xvnc -version # -> ...(Xvnc) ... v3.3.1.mt32...5. Specifying the thread count in
turbovncserver.confThe TurboVNC server config file is
/etc/turbovncserver.conf(system-wide)or
~/.vnc/turbovncserver.conf(per-user). It is Perl. The variable$serverArgspasses extra arguments straight toXvnc.Add this line:
Then start a session normally:
Other ways to set it
$serverArgs = "-nthreads 32";inturbovncserver.confvncserver -nthreads 32export TVNC_NTHREADS=32beforevncservermin(CPU cores, 32)per frameNotes:
1–32. The server still clamps to your CPU core count.-nthreadsis a hard ceiling. The encoder picks fewer threads per framefor small screen updates (dynamic scaling) — small updates never spawn 32
threads, so there is no contention overhead.
-nthreads 32runs a true 32. Realistic sweet spot is~8–16; benchmark
-nthreads4 / 8 / 16 / 32 on your real workload and pickthe knee.
To confirm it took effect, check the session log (
~/.vnc/*.log):6. What was tested
Built and run on Rocky 8 (Xvnc
3.3.1.mt32):-nthreads 32, real 32 threads, 2560×1440, 40 s motion-nthreads 32under load-nthreads1 / 4 / 8 / 16 / 24 / 32-nthreads 32Recommended on your real box: also run
-nthreads4/8/16/32 benchmarks(
TVNC_PROFILE=1env logs encoder throughput) to find your knee.