perf(moe): keep the CPU MoE pool and its expert banks on one NUMA node - #18
perf(moe): keep the CPU MoE pool and its expert banks on one NUMA node#18gdevenyi wants to merge 1 commit into
Conversation
`cpu_moe_ext.cpp` states the assumption outright -- "NUMA: a single node is
assumed" -- but nothing enforced it. `resolve_threads_and_affinity(0)` returned
every physical core on the machine, so on a 2-socket box half the workers read
expert bytes over the interconnect and the sense-reversing barrier and the
`p1_next`/`p2_next` work counters ping-ponged their cache lines across it.
Measured on 2x Xeon Gold 6526Y (16c/socket, 4 of 8 DDR5-5200 channels populated,
RTX 6000 Ada on node0), `ft bench bw`, median of 5, no numactl:
dtype before after delta
bf16 67.5 96.2 +42.5%
ds_fp4 66.9 86.9 +29.9%
mxfp4 48.5 59.8 +23.3%
nvfp4 64.4 75.7 +17.5%
Two halves, and neither works alone -- confining the workers while the banks
land wherever the loader threads ran made throughput *bimodal*, 56-123 GB/s run
to run against a steady 69 before. Both together are stable: bf16 now spans
94.2-99.0 across five runs.
* Threads: `moe_pool_numa_node()` picks the GPU's node, so the expert GEMV and
the offload gather's DMA are both local. Confining would otherwise halve the
pool, so the default there fills the node's SMT siblings too -- the thread
count is unchanged and only the placement moves. That matters for the
compute-bound formats: at one-thread-per-core on half the cores mxfp4 lost
23%, and SMT brings it back to +23%.
* Banks: `HostBank` mmaps are `mbind(MPOL_PREFERRED)`-ed to that node before
the fill faults them in -- pin-after-fill means nothing is resident yet,
which is the only moment placement is free. A preference, not a
reservation: the banks are 130+ GiB and `MPOL_BIND` would OOM rather than
spill once the node fills, a far worse failure than a remote read.
`mbind` goes through `syscall()` rather than libnuma, which glibc does not wrap,
so no new runtime dependency. `benchbw` places its synthetic banks with
`set_mempolicy` instead: it allocates via `cudaHostAlloc`, which faults and pins
up front, and pinned pages can never be migrated.
Single-node machines -- FreeToken's usual target -- are untouched: with fewer
than two nodes the resolver returns exactly what it always did, and every
placement call is a no-op. Same on non-Linux, on an unknown syscall ABI, and
under `FREETOKEN_CPU_MOE_NUMA=off`. `test_single_node_*` and
`test_no_node_argument_matches_old_behaviour` are the regression guards.
`FREETOKEN_CPU_MOE_NUMA` takes `auto` (default), `off`, or a node id. No boolean
spellings: "0" and "1" are node ids, so on/off would be ambiguous.
The torch intra-op clamp keeps reading `physical_core_cpus(pool_node)` rather
than the machine-wide count, so it lands exactly where it did before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
|
Correction to the first follow-up bullet above, before it misleads anyone. I wrote that mxfp4 could take nvfp4's W4A8 VNNI path since both use the same e2m1 codes. That is wrong — the codes match but the bank layouts are orthogonal:
Making VNNI usable there needs The observation that stands is that mxfp4 is the only compute-bound format (59.8 GB/s vs bf16's 96.2 on more bytes per expert), and that its decode chain consumes only 16 of 64 byte lanes per load. Options that stay inside the current layout — 64-byte None of this affects the change in this PR; mxfp4 is +23.3% here purely from placement. |
|
Moving this to draft. Do not merge — it is a net regression in real serving, and the numbers in the description are misleading. I finally measured end to end instead of trusting
~6.7% slower, reproducible, order-controlled, page cache dropped beforehand and both NUMA nodes balanced at ~251 GB free at the start. The microbenchmark in the description says +29.9% for this same format on this same machine. Both numbers are real; the microbenchmark simply measures the wrong thing. Why
Confinement also puts 31 workers on node0's 16 physical cores while the flag-sync coordinator, the API server, and the driver threads all now contend for the same node, where before they had a whole idle socket. What I think is salvageableThe machinery (topology detection, The untested hypothesis worth checking before anyone revives this: under I have not tested that yet, so I am not re-proposing it — just recording where the evidence points. Apologies for the noisy description; the +42.5%/+29.9% figures should be read as "isolated CPU GEMV only", not as serving throughput. |
|
Tested the hypothesis from my last comment. It is refuted, and worse than I expected — so I am closing this. I guessed that confinement would win under
28% slower — four times the penalty seen with What is actually going onPure-CPU expert decode is the most DRAM-hungry configuration there is: every expert byte of every layer is streamed from host memory, read once, never reused. What matters for a read-once streaming workload is aggregate memory-controller bandwidth, not locality. Spanning both sockets gets ~333 GB/s across two memory controllers; confining to one node gets ~166 GB/s. A remote read over UPI is cheaper than losing half your bandwidth. That also explains why So Closing. Sorry for the noise — the microbenchmark numbers in the description were real but measured something that does not predict serving throughput. What might actually be worth doingNot confinement — interleaving. Bank pages currently land wherever the 8 unpinned loader threads happen to run, so the split between nodes is a lottery. |
Problem
cpu_moe_ext.cpp:1094states the assumption outright — "NUMA: a single node is assumed" — but nothing enforced it.resolve_threads_and_affinity(0)returned every physical core on the machine, so on a 2-socket box half the workers read expert bytes over the interconnect, and the sense-reversing barrier plus thep1_next/p2_nextwork counters ping-pong their cache lines across it.On a 2× Xeon Gold 6526Y the pool reached 26% of the STREAM ceiling spanning both sockets, against 91% confined to one.
Results
ft bench bw, median of 5, nonumactl— this is the default-path delta a user sees:Hardware: 16c/socket, 4 of 8 DDR5-5200 channels populated per socket, 2× RTX 6000 Ada both on node0, Ubuntu 24.04, torch 2.11.0+cu130.
Two halves, and neither works alone
Confining the workers while the banks stay unplaced made things worse, not better — throughput went bimodal, 56–123 GB/s run to run, against a steady 69 before. The banks were landing wherever the loader threads happened to run. Both halves together are stable: bf16 spans 94.2–99.0 across five runs.
moe_pool_numa_node()picks the GPU's node, keeping the expert GEMV and the offload gather's DMA local. Confining would otherwise halve the pool, so the default there fills the node's SMT siblings too: the thread count is unchanged and only the placement moves. That matters for the compute-bound formats — at one-thread-per-core on half the cores mxfp4 lost 23%, and SMT brings it back to +23%.HostBankmmaps getmbind(MPOL_PREFERRED)before the fill faults them in. Pin-after-fill means nothing is resident yet, which is the only moment placement is free. A preference, not a reservation: the banks are 130+ GiB andMPOL_BINDwould OOM rather than spill once the node fills — a far worse failure than a remote read.mbindgoes throughsyscall()rather than libnuma (glibc does not wrap it), so no new runtime dependency. Verified at page level:prefer:1 … N1=16384.benchbwplaces its synthetic banks withset_mempolicyinstead — it allocates viacudaHostAlloc, which faults and pins up front, and pinned pages can never be migrated.Not breaking single-socket
FreeToken's usual target is a single-socket desktop, where this must be a no-op. With fewer than two nodes the resolver returns exactly what it always did and every placement call short-circuits. Same on non-Linux, on an unknown syscall ABI, and under
FREETOKEN_CPU_MOE_NUMA=off.test_single_node_is_not_confined,test_single_node_pool_is_unchanged,test_no_node_argument_matches_old_behaviourandtest_unreadable_topology_is_not_confinedare the regression guards.One subtlety worth flagging for review: the torch intra-op clamp at
cpu_executor.pykeeps readingphysical_core_cpus(pool_node)rather than the machine-wide count, so it lands exactly where it did before. Left as-is deliberately — retuning it is a separate question.Escape hatch
FREETOKEN_CPU_MOE_NUMAtakesauto(default),off, or a node id. No boolean spellings on purpose:0and1are node ids, so on/off would make the common case ambiguous.Tests
20 new tests in
tests/moe/test_cpu_pool_numa.py, including one that mbinds a real mapping and asserts the policy takes (skipped on single-node machines).Full suite on the target: 743 passed, 6 skipped, 1 failed — the failure is
tests/moe/test_cpu_moe_q4_0.py::test_cpu_decode_q4_0_matches_ggml_mmvq, which also fails onmainat the same commit and is unrelated.ruff checkintroduces no new violations on any modified file (counts identical to HEAD).Follow-ups (not in this PR)
mxfp4still runs the W4A16permutexvar+fp32 path whilenvfp4has a W4A8 VNNI one, using the same e2m1 codes — it is the only compute-bound format and the reason it needs SMT to break even.🤖 Generated with Claude Code
https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun