You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
TP > 1: every rank keeps torch's default intra-op thread count, so a multi-rank weight load busy-waits itself to a standstill on a small-core host (19 min -> 61 s) #371
Nothing in python/freetoken/ sets torch.set_num_threads() on the load path, so every spawned TP
rank (server/launch.py:165) inherits torch's default of one intra-op thread per logical CPU. At tp_size = N that puts N x cpu_count OpenMP threads on the host's physical cores, and libgomp's
default OMP_WAIT_POLICY=ACTIVE makes every idle thread busy-wait at each fork/join.
On a 6-core host at --tp-size 2 that is 24 threads on 6 cores, and each ~400 KB Tensor.copy_ in
the expert placement loop costs ~8.4 ms instead of ~0.04 ms. The cold load takes 19 minutes
instead of 61 seconds. The disk is idle throughout and both ranks sit at ~600 % CPU.
The trigger is tp_size x cpu_count > physical cores, so it is a property of the host shape, not of
any vendor or model. A large-core host at TP=2 will likely never see it.
The measurement, part 1: the mechanism, CPU only
A standalone harness, no GPU passed to the container, no model loaded, no server started. It imports freetoken.models.weight.iter_expert_tensors_parallel and replays the six placement writes of models/nvfp4_banks.py::load_nvfp4_expert_source_banks_parallel verbatim, in two spawn children
started the way server/launch.py starts ranks. Checkpoint is Qwen3.8-Flash-Next NVFP4: 193 expert
shards, 63.3 GiB, H=2560 I=640 E=512 L=48.
96 of the 193 expert shards, 31.66 GiB per rank, two ranks:
arm
rank wall
rate
as shipped (12 threads/rank, ACTIVE)
604.84 s
54 MiB/s
OMP_WAIT_POLICY=PASSIVE
15.94 s
2034 MiB/s (37.9x)
torch.set_num_threads(3)
12.82 s
2529 MiB/s (47.2x)
Thread-count sweep, 20 shards, two ranks:
threads per rank
rank wall
1
3.98 s
2
3.17 s
3
2.98 s
4
3.00 s
6
3.55 s
12 (the default)
131.85 s
Every count from 1 to 6 removes the collapse; only the default triggers it. The cliff is exactly at 2 ranks x 12 > 12 logical CPUs.
The tell that it is barrier overhead and not copy cost: with the default, all six placement
writes cost ~21.5 s each over 2560 calls — ~8.4 ms per call — and the write that moves one eighth
the bytes costs the same as the one that moves eight eighths. A copy whose cost is independent of its
size is not a copy cost.
What it is not. Two concurrent ranks move 13.19 GiB with 1.82 s each inside preadv (~7.2 GB/s
aggregate), so the disk is not the term. One process running the same placement shape finishes in
2.74 s, so the shape is not the term either. Only two ranks at once with the default thread count
collapses.
PASSIVE costs the single-rank case nothing: 2.76 s with it against 2.94 s without, one process, 20
shards.
The measurement, part 2: the real cold load, both GPUs
One env line on the container, OMP_WAIT_POLICY=PASSIVE. Same image, same command, no rebuild, no
code change. Control is the same box's own load ten hours earlier.
term
as shipped
PASSIVE
startup -> dense weights -> PLE
7 s
7 s
expert read
1147 s
32 s
pin settle (exposed once the read shrinks)
3 s
3 s
CUDA-graph capture
18 s
18 s
relay -> ready
0 s
1 s
load to ready
1175 s
61 s
The engine's own expert progress bar: 63.3G/63.3G [00:32<00:00, 2.48GB/s] against the control's [19:07<00:00, 59.7MB/s].
Decode afterwards is unchanged — 35.2 / 35.2 / 35.7 / 35.7 tok/s, the same band as before the change
— so PASSIVE is not paid back at serve time even though it is process-wide and applies to every
ATen CPU parallel region for the server's life.
Suggested fix
server/launch.py::_run_scheduler, after import torch and before Scheduler(args):
physical_core_cpus() already exists at moe/cpu_executor.py:92, and cpu_executor.py:246-253
already does exactly this clamp-and-log — it just runs after the expert load, so it does not help
here. The two compose: the later clamp only ever lowers the count.
Measured, the clamp is slightly better than the env var (47.2x against 37.9x), which on our load is
worth ~6 s out of 61. The reason to prefer it is not the 6 s — it is that it is tp_size-aware and
protects every host, where an env var protects one deployment.
Happy to open this as a PR if you would like it in that form.
On upstream main each TP rank loads the full expert set rather than half, so if anything this
should be worse there than here, not better. We have not run upstream main at TP=2 to confirm
that and are not claiming it.
Measured on the offloaded-MoE expert-bank load path. Any ATen CPU parallel region during a
multi-rank load should be exposed the same way; we only measured this one.
No quality claim — this box has no fidelity instrument, and nothing here touches numerics.
Possibly adjacent, explicitly not claimed as the same bug: ft serve hangs at 96% weight load on Threadripper 1950X + RTX 5060 Ti #261 (ft serve hangs at 96 %
weight load, load average > 10, box unresponsive) is single-rank, so this mechanism does not apply.
But "CPU pegged, disk idle, machine unresponsive" is the same signature, and OMP_WAIT_POLICY=PASSIVE is a free thing to ask that reporter to try.
Summary
Nothing in
python/freetoken/setstorch.set_num_threads()on the load path, so every spawned TPrank (
server/launch.py:165) inherits torch's default of one intra-op thread per logical CPU. Attp_size = Nthat putsN x cpu_countOpenMP threads on the host's physical cores, and libgomp'sdefault
OMP_WAIT_POLICY=ACTIVEmakes every idle thread busy-wait at each fork/join.On a 6-core host at
--tp-size 2that is 24 threads on 6 cores, and each ~400 KBTensor.copy_inthe expert placement loop costs ~8.4 ms instead of ~0.04 ms. The cold load takes 19 minutes
instead of 61 seconds. The disk is idle throughout and both ranks sit at ~600 % CPU.
The trigger is
tp_size x cpu_count > physical cores, so it is a property of the host shape, not ofany vendor or model. A large-core host at TP=2 will likely never see it.
The measurement, part 1: the mechanism, CPU only
A standalone harness, no GPU passed to the container, no model loaded, no server started. It imports
freetoken.models.weight.iter_expert_tensors_paralleland replays the six placement writes ofmodels/nvfp4_banks.py::load_nvfp4_expert_source_banks_parallelverbatim, in twospawnchildrenstarted the way
server/launch.pystarts ranks. Checkpoint is Qwen3.8-Flash-Next NVFP4: 193 expertshards, 63.3 GiB,
H=2560 I=640 E=512 L=48.96 of the 193 expert shards, 31.66 GiB per rank, two ranks:
ACTIVE)OMP_WAIT_POLICY=PASSIVEtorch.set_num_threads(3)Thread-count sweep, 20 shards, two ranks:
Every count from 1 to 6 removes the collapse; only the default triggers it. The cliff is exactly at
2 ranks x 12 > 12 logical CPUs.The tell that it is barrier overhead and not copy cost: with the default, all six placement
writes cost ~21.5 s each over 2560 calls — ~8.4 ms per call — and the write that moves one eighth
the bytes costs the same as the one that moves eight eighths. A copy whose cost is independent of its
size is not a copy cost.
What it is not. Two concurrent ranks move 13.19 GiB with 1.82 s each inside
preadv(~7.2 GB/saggregate), so the disk is not the term. One process running the same placement shape finishes in
2.74 s, so the shape is not the term either. Only two ranks at once with the default thread count
collapses.
PASSIVEcosts the single-rank case nothing: 2.76 s with it against 2.94 s without, one process, 20shards.
The measurement, part 2: the real cold load, both GPUs
One env line on the container,
OMP_WAIT_POLICY=PASSIVE. Same image, same command, no rebuild, nocode change. Control is the same box's own load ten hours earlier.
PASSIVEThe engine's own expert progress bar:
63.3G/63.3G [00:32<00:00, 2.48GB/s]against the control's[19:07<00:00, 59.7MB/s].Decode afterwards is unchanged — 35.2 / 35.2 / 35.7 / 35.7 tok/s, the same band as before the change
— so
PASSIVEis not paid back at serve time even though it is process-wide and applies to everyATen CPU parallel region for the server's life.
Suggested fix
server/launch.py::_run_scheduler, afterimport torchand beforeScheduler(args):physical_core_cpus()already exists atmoe/cpu_executor.py:92, andcpu_executor.py:246-253already does exactly this clamp-and-log — it just runs after the expert load, so it does not help
here. The two compose: the later clamp only ever lowers the count.
Measured, the clamp is slightly better than the env var (47.2x against 37.9x), which on our load is
worth ~6 s out of 61. The reason to prefer it is not the 6 s — it is that it is
tp_size-aware andprotects every host, where an env var protects one deployment.
Happy to open this as a PR if you would like it in that form.
Scope, and what we did not measure
(that is Offloaded MoE ignores tensor parallelism: TP=2 is a regression, and the fix is not expert parallelism #62's subject), feat(qwen4_exp): stream the PLE n-gram table from disk #311's disk PLE, and a chunked-multimodal-prefill patch. The mechanism
does not depend on any of them — the harness imports your reader and replays your placement
writes, and the trigger is only "N spawned ranks each defaulting to
cpu_countintra-op threads".maineach TP rank loads the full expert set rather than half, so if anything thisshould be worse there than here, not better. We have not run upstream
mainat TP=2 to confirmthat and are not claiming it.
multi-rank load should be exposed the same way; we only measured this one.
ft servehangs at 96 %weight load, load average > 10, box unresponsive) is single-rank, so this mechanism does not apply.
But "CPU pegged, disk idle, machine unresponsive" is the same signature, and
OMP_WAIT_POLICY=PASSIVEis a free thing to ask that reporter to try.Platform
AMD Ryzen 5 7400 (6 physical cores / 12 logical) · AMD Radeon AI PRO R9700 x2 (
gfx1201),Linux, 122 GiB host RAM · PyTorch 2.11.0+rocm7.14.0, HIP 7.14.60850, RCCL 2.30.4 · FreeToken
main@4b94bdc3+ #132/#133/#134 + #311 + our sharding work.The core count is the whole story here; the GPUs are incidental.