Skip to content

--numa mirror: mirror model weights to every Numa node in the system#16000

Draft
dbsanfte wants to merge 25 commits into
ggml-org:masterfrom
dbsanfte:numa-mirror
Draft

--numa mirror: mirror model weights to every Numa node in the system#16000
dbsanfte wants to merge 25 commits into
ggml-org:masterfrom
dbsanfte:numa-mirror

Conversation

@dbsanfte

@dbsanfte dbsanfte commented Sep 15, 2025

Copy link
Copy Markdown

This PR adds a new --numa mirror option which mirrors model weights to each Numa node on the system, and uses a thread-local var in the OMP threadpool to select the correct mirror copy local to the thread at runtime, to eliminate cross-socket traffic.

Build instructions:

apt-get update
apt-get install -y libnuma-dev libgomp1
cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-march=native" -DCMAKE_CXX_FLAGS="-march=native"  -DGGML_OPENMP=ON
cmake --build build --parallel

To test:

# No mirroring:
./build/bin/llama-bench -m ~/models/Qwen3-30B-A3B-UD-Q4_K_XL.gguf

# Numa mirroring of model weights to every node:
./build/bin/llama-bench -m ~/models/Qwen3-30B-A3B-UD-Q4_K_XL.gguf --numa mirror

Test system is a two-socket Xeon 6238R Cascade Lake, with 768GB of DDR4-2933 (6 channels per socket).

Without --numa mirror:

developer@81ec6c6e6af6:/workspaces/llama-cpp-dbsanfte-dev$ ./build/bin/llama-bench -m ./.devcontainer/Qwen3-32B-Q6_K.gguf     
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| qwen3 32B Q6_K                 |  25.03 GiB |    32.76 B | CPU        |      56 |           pp512 |         20.99 ± 0.01 |
| qwen3 32B Q6_K                 |  25.03 GiB |    32.76 B | CPU        |      56 |           tg128 |          1.91 ± 0.00 |

build: c665d3c9 (6468)

With --numa mirror:

developer@81ec6c6e6af6:/workspaces/llama-cpp-dbsanfte-dev$ ./build/bin/llama-bench -m .
/.devcontainer/Qwen3-32B-Q6_K.gguf --numa mirror
| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| qwen3 32B Q6_K                 |  25.03 GiB |    32.76 B | CPU        |      56 |           pp512 |         21.36 ± 0.11 |
| qwen3 32B Q6_K                 |  25.03 GiB |    32.76 B | CPU        |      56 |           tg128 |          2.70 ± 0.00 |

build: c665d3c9 (6468)

Intel PCM tool during mirror inference showing both sockets using local mem:

image

There's still a bit of cross-socket traffic (5%) because only model weights are mirrored, not tensors created at inference time. I'll play with that, maybe mirroring those aggressively will help too, or maybe not. Right now anything created at inference time just gets set to live on Node 0.

- Achieved 5% inference speed improvement (14.6 -> 15.3 t/s)
- Clean explicit NUMA setup during model loading
- Ultra-minimal hot path with thread-local NUMA node access
- Working NUMA mirrors for all model weights
- Performance: text generation improved, prompt processing needs optimization

Performance Results (Qwen3-30B-A3B):
- Text Generation: 14.6 -> 15.3 t/s (+5% improvement)
- Prompt Processing: 176 -> 152 t/s (14% regression - needs investigation)

Technical Implementation:
- tensor_data(): O(1) NUMA-aware access via thread-local ggml_current_numa_node
- tensor_set_data_with_numa_mirrors(): Explicit NUMA setup for model weights
- NUMA coordinator: Thread binding and memory locality
- Clean separation: model loading (explicit setup) vs inference (fast access)
@github-actions github-actions Bot added testing Everything test related Nvidia GPU Issues specific to Nvidia GPUs Vulkan Issues specific to the Vulkan backend examples python python script changes devops improvements to build systems and github actions ggml changes relating to the ggml tensor library for machine learning SYCL https://en.wikipedia.org/wiki/SYCL - GPU programming language Apple Metal https://en.wikipedia.org/wiki/Metal_(API) Ascend NPU issues specific to Ascend NPUs OpenCL Issues specific to the OpenCL backend IBM zDNN issues specific to IBM zDNN Accelerator labels Sep 15, 2025
@dbsanfte dbsanfte marked this pull request as draft September 15, 2025 06:14
@dbsanfte

dbsanfte commented Sep 15, 2025

Copy link
Copy Markdown
Author

Physical core detection was very broken in arg.cpp / common.cpp, it assumed every 2nd core consecutively was a hyperthread. This isn't true on Xeons at least - Physical cores are my first 56, then the next 56 are the hyperthreads. This led to wildly inconsistent results at inference time. Now I use proper CPU topology detection to choose only physical cores. But if you still want to use hyperthreads I added a new option: --cpu-use-hyperthreading.

@jukofyork

Copy link
Copy Markdown
Collaborator

@Ph0rk0z Actually there is something extra you have to do in ik_llama.cpp to make this work:

Changes in 'src/llama.cpp':
-------------------
5823c5823
<     if (params.only_active_experts) {
---
>     if (false) {
-------------------

but this was only done for the original hack as I needed to save the tensor names in the cache, and we should later be able to patch a different function (which I forget the name of now) that ik_llama.cpp uses when it makes the "only_active_experts" copy... For now you will have to patch that line in src/llama.cpp though (the line number might be wrong as I'm using an older version of ik_llama.cpp currently).

@Ph0rk0z

Ph0rk0z commented May 26, 2026

Copy link
Copy Markdown

For that I think you can -no-ooae

@jukofyork

Copy link
Copy Markdown
Collaborator

My local cores are like 0-23, do we put all of them or just a subset or that's unknown?

You have to run numactl -H to find which CPU indices. There is another command to find which GPU your CPU is connected to, but I can't remember it - it is very obvious is you use the wrong set of indices though as you will have lots of cores running 15-25% and 1 core running 80-100% during PP, and if these are not all on the same CPU it will be passing the data back through the QPI links again (I think this might actually be why my E5-2699v4 machines are only getting 10.5GB/s - testing it now).

@Ph0rk0z

Ph0rk0z commented May 26, 2026

Copy link
Copy Markdown

Everything I have is on one node and those are the only "real" cores on it.

@jukofyork

Copy link
Copy Markdown
Collaborator

Seems like there is probably a fair potential for tuning:

static constexpr size_t BUFFER_BYTES = 1024ULL * 1024 * 1024;
static const int GPU_LOCAL_CORES[]   = {0, 1, 2, 3, 4, 5, 6, 7};

prompt eval time =   89362.30 ms / 19744 tokens (    4.53 ms per token,   220.94 tokens per second)
       eval time =  204472.86 ms /  1744 tokens (  117.24 ms per token,     8.53 tokens per second)
      total time =  293835.15 ms / 21488 tokens
static constexpr size_t BUFFER_BYTES = 512ULL * 1024 * 1024;
static const int GPU_LOCAL_CORES[]   = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // All cores on NODE0:CPU0 (Xeon Gold 6248) minus NODE0:CPU0:CORE0 (= core#0)

prompt eval time =   89878.40 ms / 19744 tokens (    4.55 ms per token,   219.67 tokens per second)
       eval time =  137044.00 ms /  1147 tokens (  119.48 ms per token,     8.37 tokens per second)
      total time =  226922.40 ms / 20891 tokens

static constexpr size_t BUFFER_BYTES = 1024ULL * 1024 * 1024;
static const int GPU_LOCAL_CORES[]   = {0, 1, 2, 3, 4, 5, 6, 7};

prompt eval time =  150279.92 ms / 20151 tokens (    7.46 ms per token,   134.09 tokens per second)
       eval time =  342978.97 ms /  1981 tokens (  173.13 ms per token,     5.78 tokens per second)
      total time =  493258.89 ms / 22132 tokens
static constexpr size_t BUFFER_BYTES = 512ULL * 1024 * 1024;
static const int GPU_LOCAL_CORES[]   = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }; // All cores on NODE0:CPU0 (Xeon E5-2699v4) minus NODE0:CPU0:CORE0 (= core#0)

prompt eval time =  119965.42 ms / 20151 tokens (    5.95 ms per token,   167.97 tokens per second)
       eval time =  392206.81 ms /  2294 tokens (  170.97 ms per token,     5.85 tokens per second)
      total time =  512172.23 ms / 22445 tokens

@Ph0rk0z

Ph0rk0z commented May 26, 2026

Copy link
Copy Markdown

Not sure it's doing much yet... proc is a QQ89 so like 82xx

stepfun-ai_Step-3.5-Flash-Q4_K_L

layer with the patch, 0-23

main: n_kv_max = 65536, n_batch = 2048, n_ubatch = 2048, flash_attn = 1, n_gpu_layers = 46, n_threads = 48, n_threads_batch = 48

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.370 381.41 13.481 37.98
2048 512 2048 5.066 404.23 13.792 37.12
2048 512 4096 5.140 398.44 14.038 36.47
2048 512 6144 5.167 396.35 14.241 35.95
2048 512 8192 5.158 397.07 14.459 35.41
2048 512 10240 5.202 393.71 14.674 34.89
2048 512 12288 5.244 390.51 14.872 34.43
2048 512 14336 5.263 389.10 15.144 33.81
2048 512 16384 5.324 384.68 15.333 33.39

Without Patch

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.153 397.42 13.779 37.16
2048 512 2048 5.147 397.91 13.954 36.69
2048 512 4096 5.212 392.97 14.168 36.14
2048 512 6144 5.147 397.90 14.335 35.72
2048 512 8192 5.198 393.97 14.696 34.84
2048 512 10240 5.259 389.44 14.794 34.61
2048 512 12288 5.242 390.67 15.315 33.43
2048 512 14336 5.294 386.83 15.183 33.72
2048 512 16384 5.497 372.59 15.455 33.13

Graph no patch

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.182 395.24 14.056 36.43
2048 512 2048 4.981 411.13 14.414 35.52
2048 512 4096 4.934 415.07 14.491 35.33
2048 512 6144 4.962 412.76 14.600 35.07
2048 512 8192 4.991 410.34 14.675 34.89
2048 512 10240 5.085 402.79 14.817 34.55
2048 512 12288 5.049 405.62 15.106 33.89
2048 512 14336 5.040 406.33 15.143 33.81
2048 512 16384 5.046 405.84 15.063 33.99

Graph patch 0-23

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.315 385.35 14.306 35.79
2048 512 2048 5.554 368.73 14.641 34.97
2048 512 4096 4.993 410.19 14.558 35.17
2048 512 6144 5.104 401.23 14.605 35.06
2048 512 8192 5.171 396.02 14.742 34.73
2048 512 10240 5.039 406.45 14.836 34.51
2048 512 12288 5.052 405.42 14.939 34.27
2048 512 14336 5.065 404.35 15.010 34.11
2048 512 16384 5.068 404.08 15.116 33.87

Graph patch 0-11

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.188 394.76 14.090 36.34
2048 512 2048 4.979 411.29 14.302 35.80
2048 512 4096 4.989 410.50 14.357 35.66
2048 512 6144 5.003 409.33 14.461 35.41
2048 512 8192 5.007 409.05 14.544 35.20
2048 512 10240 5.008 408.94 14.672 34.90
2048 512 12288 5.051 405.44 14.773 34.66
2048 512 14336 5.087 402.57 14.854 34.47
2048 512 16384 5.083 402.87 14.955 34.24

What should be tuned?

@jukofyork

jukofyork commented May 26, 2026

Copy link
Copy Markdown
Collaborator

What should be tuned?

First make sure it's actually doing something - during (offloaded) PP: are you seeing a single core in htop running at 100% or are you seeing many cores running at lowish values (which should coincide with the core indices you set)?

@jukofyork

Copy link
Copy Markdown
Collaborator

it might also be worth adding some printf statements like my original version of this did above - this will at least confirm it is compiled in and doing something.

@Ph0rk0z

Ph0rk0z commented May 26, 2026

Copy link
Copy Markdown

I am seeing the system load at half every time. I have a CPU use and temperature on the task bar. Also watching pcm-memory throughput which seems around 54-58 gb/s.

It definitely compiles because I see it:

[  0%] Building CUDA object ggml/src/CMakeFiles/ggml.dir/ggml-cuda.cu.o
/home/supermicro/ai/ik_llama.cpp/ggml/src/ggml-cuda.cu: In function 'cuda_params ggml_cuda_parse_params(const char*)':
/home/supermicro/ai/ik_llama.cpp/ggml/src/ggml-cuda.cu:5276:20: warning: 'tmp' may be used uninitialized [-Wmaybe-uninitialized]
 5276 |                     if (tmp < 0.0f || tmp > 3.0f) {
      |                    ^~~~~~~~~~~~~~~~~
/home/supermicro/ai/ik_llama.cpp/ggml/src/ggml-cuda.cu:5273:7: note: 'tmp' was declared here
 5273 |                 float tmp;

@jukofyork

Copy link
Copy Markdown
Collaborator

I am seeing the system load at half every time. I have a CPU use and temperature on the task bar. Also watching pcm-memory throughput which seems around 54-58 gb/s.

The problem this solves might not be a problem for you then? It's worth calculating your PCI-E bandwidth to get an idea of the theoretical maximum, then watch nvtop on the GPU that processes the offloaded tensors: if it's running at 30-50% of the theoretical maximum then this might help it, but if it's already running at 75-80% it won't make any difference.

In my case: I get around 4.5GB/s without this, and 12.7GB/s with.

@Ph0rk0z

Ph0rk0z commented May 26, 2026

Copy link
Copy Markdown

I have 4x gpu, on this model the problem is the system feeding them because I only see 33-35% load with stepfun, even with graph. It's difficult to get a peak on nvtop due to it only really climbing to the gigs during prompt processing.

I should do a trace to see where the bottleneck is. On fully offloaded models it was definitely PCIE.

nsys-hybrid.txt

@usrlocalben

Copy link
Copy Markdown

@jukofyork I setup 8 threads on the cores connected to GPU. During PP in htop I observe those 8 threads on the assigned cpunums at e.g. 50%, plus one 100%, but the 100% core is on the other (i.e. wrong) CPU.

😖

@jukofyork

jukofyork commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

@jukofyork I setup 8 threads on the cores connected to GPU. During PP in htop I observe those 8 threads on the assigned cpunums at e.g. 50%, plus one 100%, but the 100% core is on the other (i.e. wrong) CPU.

😖

Yeah, it seems the best way to avoid this is to leave the first 2 cores free on the CPU connected to the GPU:

// ==================================================================
// CUDA pinned-buffer patch for ik_llama.cpp
// Replaces the CPU→GPU memcpy for "_exps" tensors with:
//   1. Parallel CPU gather (pinned NUMA-local threads)
//   2. Double-buffered async DMA (overlap CPU work with GPU transfer)
// ==================================================================

#include <pthread.h>
#include <thread>
#include <algorithm>
#include <mutex>
#include <vector>
#include <sstream>
#include <string>
#include <cstdlib>
#include <cassert>
#include <queue>
#include <unordered_map>
#include <condition_variable>
#include <functional>
#include <memory>

// ------------------------------------------------------------------
// Tunables
// ------------------------------------------------------------------
static constexpr size_t BUFFER_BYTES = 512ULL * 1024 * 1024; // 0.5GB staging buffer
static constexpr size_t NUM_BUFFERS  = 2;                    // ping-pong double buffer

// ---- CPU thread settings (edit these) -----------------------------
static constexpr int CPU_THREAD_START = 2;   // skip cores 0-1 (OS / main)
static constexpr int CPU_THREAD_COUNT = 8;   // exactly this many worker threads
// ------------------------------------------------------------------

// ------------------------------------------------------------------
// 1. CPU core list (hardcoded — no env vars, no auto-detect)
// ------------------------------------------------------------------
static const std::vector<int> & get_gpu_local_cores() {
    static const std::vector<int> cores = []{
        std::vector<int> v;
        v.reserve(CPU_THREAD_COUNT);
        for (int i = CPU_THREAD_START; i < CPU_THREAD_START + CPU_THREAD_COUNT; ++i) {
            v.push_back(i);
        }
        return v;
    }();
    return cores;
}

// ------------------------------------------------------------------
// 2. Persistent thread pool (per process, not per call)
// ------------------------------------------------------------------
class LocalThreadPool {
public:
    explicit LocalThreadPool(const std::vector<int> & cores) {
        const int n = static_cast<int>(cores.size());
        for (int i = 0; i < n; ++i) {
            workers_.emplace_back([this, core = cores[i]] {
                cpu_set_t mask;
                CPU_ZERO(&mask);
                if (core >= 0 && core < CPU_SETSIZE) CPU_SET(core, &mask);
                pthread_setaffinity_np(pthread_self(), sizeof(mask), &mask);

                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(mtx_);
                        cv_.wait(lock, [this]{ return stop_ || !tasks_.empty(); });
                        if (stop_ && tasks_.empty()) return;
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();
                }
            });
        }
    }

    void enqueue(std::function<void()> task) {
        {
            std::unique_lock<std::mutex> lock(mtx_);
            tasks_.push(std::move(task));
        }
        cv_.notify_one();
    }

    void wait() {
        {
            std::unique_lock<std::mutex> lock(mtx_);
            cv_.wait(lock, [this]{ return tasks_.empty(); });
        }
    }

    ~LocalThreadPool() {
        {
            std::unique_lock<std::mutex> lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto & t : workers_) if (t.joinable()) t.join();
    }

private:
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
};

// singleton: created on first chunked memcpy, reused forever
static LocalThreadPool & get_pool() {
    static LocalThreadPool pool(get_gpu_local_cores());
    return pool;
}

// ------------------------------------------------------------------
// 3. Parallel CPU memcpy
// ------------------------------------------------------------------
static void parallel_memcpy_node_local(void * dst, const void * src, size_t n) {

    const auto & cores = get_gpu_local_cores();
    const int num_threads = static_cast<int>(cores.size());

    if (n < 4ULL * 1024 * 1024 || num_threads <= 1) {
        memcpy(dst, src, n);
        return;
    }

    auto & pool = get_pool();
    std::mutex done_mtx;
    int done = 0;
    std::condition_variable done_cv;

    for (int t = 0; t < num_threads; ++t) {
        size_t off = (n * t) / num_threads;
        size_t len = (n * (t + 1)) / num_threads - off;
        pool.enqueue([=, &done, &done_mtx, &done_cv]{
            memcpy((char *)dst + off, (const char *)src + off, len);
            {
                std::unique_lock<std::mutex> lock(done_mtx);
                ++done;
            }
            done_cv.notify_one();
        });
    }

    {
        std::unique_lock<std::mutex> lock(done_mtx);
        done_cv.wait(lock, [&]{ return done == num_threads; });
    }
}

// ------------------------------------------------------------------
// 4. GPU staging scratch area
// ------------------------------------------------------------------
struct PinnedScratch {
    void * scratch = nullptr;
    cudaEvent_t evt[NUM_BUFFERS] = {};
    std::mutex xfer_mtx;

    PinnedScratch() {
        CUDA_CHECK(cudaHostAlloc(&scratch, NUM_BUFFERS * BUFFER_BYTES, cudaHostAllocDefault));
        for (size_t i = 0; i < NUM_BUFFERS; ++i) {
            CUDA_CHECK(cudaEventCreate(&evt[i]));
        }
    }
};

static PinnedScratch & get_scratch(int cuda_device) {
    static std::unordered_map<int, std::unique_ptr<PinnedScratch>> table;
    static std::mutex mtx;
    std::lock_guard<std::mutex> lock(mtx);
    auto & ps = table[cuda_device];
    if (!ps) {
        cpu_set_t cpuset;
        CPU_ZERO(&cpuset);
        const auto & cores = get_gpu_local_cores();
        CPU_SET(cores.empty() ? 0 : cores[0], &cpuset);
        pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
        ps = std::make_unique<PinnedScratch>();
    }
    return *ps;
}

// ------------------------------------------------------------------
// 5. Hook: ggml CUDA tensor upload
// ------------------------------------------------------------------
GGML_CALL static void ggml_backend_cuda_buffer_set_tensor(
    ggml_backend_buffer_t buffer,
    ggml_tensor * tensor,
    const void * data,
    size_t offset,
    size_t size)
{
    ggml_backend_cuda_buffer_context * ctx =
        (ggml_backend_cuda_buffer_context *)buffer->context;

    ggml_cuda_set_device(ctx->device);

    if (strstr(tensor->name, "_exps") == nullptr) {
        CUDA_CHECK(cudaMemcpyAsync((char *)tensor->data + offset,
                                   data,
                                   size,
                                   cudaMemcpyHostToDevice,
                                   cudaStreamPerThread));
        CUDA_CHECK(cudaStreamSynchronize(cudaStreamPerThread));
        return;
    }

    auto & ps = get_scratch(ctx->device);
    const size_t CHUNK = BUFFER_BYTES;

    std::lock_guard<std::mutex> lock(ps.xfer_mtx);

    const size_t num_chunks = (size + CHUNK - 1) / CHUNK;

    for (size_t i = 0; i < num_chunks; ++i) {
        size_t pos   = i * CHUNK;
        size_t chunk = std::min(CHUNK, size - pos);
        size_t cur   = i % NUM_BUFFERS;

        if (i >= NUM_BUFFERS) {
            CUDA_CHECK(cudaEventSynchronize(ps.evt[cur]));
        }

        char * cur_buf   = (char *)ps.scratch + cur * CHUNK;
        const char * src = (const char *)data + pos;

        parallel_memcpy_node_local(cur_buf, src, chunk);

        if (i > 0) {
            size_t prev_i     = i - 1;
            size_t prev_pos   = prev_i * CHUNK;
            size_t prev_chunk = std::min(CHUNK, size - prev_pos);
            size_t prev       = prev_i % NUM_BUFFERS;
            char * prev_buf   = (char *)ps.scratch + prev * CHUNK;
            char * prev_dst   = (char *)tensor->data + offset + prev_pos;

            CUDA_CHECK(cudaMemcpyAsync(prev_dst, prev_buf, prev_chunk,
                                       cudaMemcpyHostToDevice,
                                       cudaStreamPerThread));
            CUDA_CHECK(cudaEventRecord(ps.evt[prev], cudaStreamPerThread));
        }
    }

    if (num_chunks > 0) {
        size_t last_i     = num_chunks - 1;
        size_t last_pos   = last_i * CHUNK;
        size_t last_chunk = std::min(CHUNK, size - last_pos);
        size_t last_cur   = last_i % NUM_BUFFERS;
        char * last_buf   = (char *)ps.scratch + last_cur * CHUNK;
        char * last_dst   = (char *)tensor->data + offset + last_pos;

        CUDA_CHECK(cudaMemcpyAsync(last_dst, last_buf, last_chunk,
                                   cudaMemcpyHostToDevice,
                                   cudaStreamPerThread));
    }

    CUDA_CHECK(cudaStreamSynchronize(cudaStreamPerThread));
}

As before, find and replace ggml_backend_cuda_buffer_set_tensor (and if using ik_llama.cpp disable only_active_experts), and make sure whatever you set these to:

static constexpr size_t BUFFER_BYTES = 512ULL * 1024 * 1024; // 0.5GB staging buffer
static constexpr size_t NUM_BUFFERS  = 2;                    // ping-pong double buffer

fit properly with your specific NUMA architecture eg: (use the numactl - H printout to help see).


Using a fixed set of worker threads seems to have helped my E5-2699v4 system get pretty close to full PCI-E 3.0 16x now, but I don't seem able to improve on 512MB and 8 threads... If you try changing these then try to stick to powers of 2 for the cache and number of threads.

There are some other CUDA functions that might help (eg: cudaMemPrefetchAsync), but I doubt if I'll get time to try anything in the near future - at least now we don't need 2x the RAM! 😁

@jukofyork

Copy link
Copy Markdown
Collaborator

Also I wouldn't bother trying to increase the buffer (or number of buffers) - it will just add extra latency and from what I've read there might be some finite limit to the amount of memory you are allowed to allocate with cudaHostAlloc and 2x512MB is already a lot larger than any examples I saw (some used as little as 16MB), but for me at least; 512MB and 8 threads seems pretty optimal and I start to get a big hit if I half either...

@Ph0rk0z

Ph0rk0z commented Jun 3, 2026

Copy link
Copy Markdown

There's some slight uplift on this one:

Stock:

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.147 397.86 14.141 36.21
2048 512 2048 5.090 402.37 14.601 35.07
2048 512 4096 5.004 409.27 14.516 35.27
2048 512 6144 4.967 412.35 14.603 35.06
2048 512 8192 4.992 410.27 14.726 34.77
2048 512 10240 5.074 403.61 14.822 34.54
2048 512 12288 5.096 401.90 14.895 34.37
2048 512 14336 5.027 407.37 14.984 34.17
2048 512 16384 5.030 407.16 15.054 34.01

New patch 10c

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.170 396.14 14.354 35.67
2048 512 2048 4.939 414.67 14.495 35.32
2048 512 4096 4.976 411.58 14.557 35.17
2048 512 6144 5.191 394.51 14.652 34.94
2048 512 8192 4.967 412.34 14.781 34.64
2048 512 10240 4.997 409.86 14.943 34.26
2048 512 12288 5.039 406.41 15.015 34.10
2048 512 14336 5.020 407.96 15.059 34.00
2048 512 16384 5.008 408.91 15.130 33.84

I chose 10 cores vs 8 because I have 24 per CPU.

Just for fun I ran it without --no-ooae

PP TG N_KV T_PP s S_PP t/s T_TG s S_TG t/s
2048 512 0 5.124 399.68 14.022 36.51
2048 512 2048 4.923 415.97 14.274 35.87
2048 512 4096 4.931 415.33 14.345 35.69
2048 512 6144 4.949 413.83 14.429 35.48
2048 512 8192 4.954 413.41 14.538 35.22
2048 512 10240 4.973 411.84 14.674 34.89
2048 512 12288 5.001 409.54 14.747 34.72
2048 512 14336 5.011 408.67 14.805 34.58
2048 512 16384 5.019 408.04 14.906 34.35

I figured it would crash? But it did not.

@jukofyork

Copy link
Copy Markdown
Collaborator

I figured it would crash? But it did not.

IIRC, it uses a different function and never calls this one with the _exp tensors:

  • The reason I couldn't make the old method work with this other function was because it was seeing a different set of tensors every call.
  • This new code is just using that scratch buffer and doesn't care what it is copying, so it should be possible to make it work now.

I wouldn't expect much change though, as at least for Kimi-K2 series of models; the auxiliary routing loss has made the distribution almost completely flat for the full 384 tensors (which I found when trying to REAP that model).

@jukofyork

Copy link
Copy Markdown
Collaborator

There's some slight uplift on this one:

Also, I would try and play with the buffer size and thread range, as even between my Broadwell E5-2699v4 machines (2x QP link) and my Cascade Lake Xeon Gold 6248 machine (3x UPI link), there seems to quite a difference in what settings work or not.

@Ph0rk0z

Ph0rk0z commented Jun 3, 2026

Copy link
Copy Markdown

I tried with qwen and saw a slightly higher 4.0gb/s transfer. So sadly this is still all minor changes. What worked better on the 6248? That's closer to my procs.

@DocShotgun

Copy link
Copy Markdown
Contributor

Just seeing these new posts lol. I've been in ktransformers land for a while running NUMA tensor parallel on Kimi K2.6 and GLM 5.1, and now I'm back to llama.cpp land for MiMo Pro and Nemotron Ultra.

Also, this should work with mmap (what I use) and numctl --interleave=all (what others sometimes use) - it's basically the same problem of single threaded transfers through the QPI links that it solves.

Doesn't interleave just make the memory pool of all the NUMA nodes behave as a single unified pool? So if data is properly distributed for NUMA, it would be better to disable it?

@jukofyork

Copy link
Copy Markdown
Collaborator

Also, this should work with mmap (what I use) and numctl --interleave=all (what others sometimes use) - it's basically the same problem of single threaded transfers through the QPI links that it solves.

Doesn't interleave just make the memory pool of all the NUMA nodes behave as a single unified pool? So if data is properly distributed for NUMA, it would be better to disable it?

Yeah, I don't use it but others have. I find I can get the best TG by flushing the buffers and using mmap to load the model.

@jukofyork

Copy link
Copy Markdown
Collaborator

What worked better on the 6248? That's closer to my procs.

Sorry missed this: I'm just using the same settings as I posted above (8 threads and 2x 512MB buffers).

@Ph0rk0z

Ph0rk0z commented Jun 7, 2026

Copy link
Copy Markdown

mmap leaves me with lower speeds since the models load off HDD. I suppose after a bunch of use it should even out.

@usrlocalben

usrlocalben commented Jun 7, 2026

Copy link
Copy Markdown

I use --numa distribute w/drop_caches protocol and mmap. AFAIK I have tried every combination and this gives the highest decode throughput w/ik_llama and llama.cpp. (Kimi Q4, 2x EPYC 9b14 NPS4, 1x R6KPMQ)

I tried Juk's follow-up above re: avoiding the low position cores, and it does indeed seem to resolve the main-thread-on-wrong-cpu problem. However, I tried various different arrangements of the thread pool, incl.

  • 4/8/16/etc. threads all on one node
    • for each node
      • for each cpu
  • a matrix of 4/8/16/etc. threads on all NUMAs on one CPU
    • for each cpu, even the wrong cpu

I expected to observe some arrangement on CPU0 (GPU attached to CPU0) to give a higher throughput, and any arrangement on CPU1 to give a significantly lower throughput. Instead I observe very little difference between any. I'm not sure what to make of this.

Slightly off-topic (but maybe interesting for NUMA users):

The fastest arrangement I found wrt. thread count, placement and buffer size is still not as fast as sglang/kt-kernel. sg/kt's layerwise-offload transfers the weights in 0.330s / layer, or ~20s. For Kimi-Q4 that's 531GiB of _exp tensors, 531 / 19.8s = 26.81GiB/s.

Compute for a fully-loaded 32768 batch is ~0.120s/layer, so 32768 / ((0.330+0.120)*60(layers)) = ~1200t/s prefill. w/o layerwise prefill it's 50-100t/s depending on context. Setting the threshold accordingly gives pretty good latency for e.g. tool calls.

At low context ik_llama's decode throughput is better by 10% or so, but that is lost once 100K is reached and sglang pulls ahead significantly.

The build/install quality of sg/kt leaves much to be desired, but once a working commit/library-build out/CUDA combo etc. is found the program itself is stable and runs w/o error for days or weeks.

...and even more off-topic (but still maybe interesting for NUMA users):

Meanwhile lvllm and lsglang appear to have matured a lot wrt. build/install quality. lvllm w/DSv4-Flash gives ~45-50t/s decode w/MTP on my setup, prefill ~1000t/s (by wall-clock measurement, the logs report values that are nonsensically high). There's high latency as the system is first running since tilelang continues to compile additional kernel variations for quite some time. Eventually this settles and the system behaves normally wrt. TTFT.

lsglang w/Kimi gives me incoherent/gibberish output, reason unknown.

I integrated kt-kernel into a DwarfStar fork here using the MXFP4 weights. This gives ~350t/s prefill and ~34t/s decode, and frees up GPU space for e.g. 500K context in ~30GB VRAM, or 1M in ~50GB. lvllm is significantly faster than this implementation, but DS4 is still interesting for its integration wrt. cache/chat/tool handling. (It also runs -Pro w/~100t/s prefill and ~10t/s decode)

@DocShotgun

DocShotgun commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

I use --numa distribute w/drop_caches protocol and mmap. AFAIK I have tried every combination and this gives the highest decode throughput w/ik_llama and llama.cpp. (Kimi Q4, 2x EPYC 9b14 NPS4, 1x R6KPMQ)

The last time I tried this, it improved decode speed but murdered my prefill speed (compared to --numa distribute + interleave + no mmap, or just keeping everything on a single socket if it fits). I haven't yet tested juk's new workaround for that.

Slightly off-topic (but maybe interesting for NUMA users):

The fastest arrangement I found wrt. thread count, placement and buffer size is still not as fast as sglang/kt-kernel. sg/kt's layerwise-offload transfers the weights in 0.330s / layer, or ~20s. For Kimi-Q4 that's 531GiB of _exp tensors, 531 / 19.8s = 26.81GiB/s.

Compute for a fully-loaded 32768 batch is ~0.120s/layer, so 32768 / ((0.330+0.120)*60(layers)) = ~1200t/s prefill. w/o layerwise prefill it's 50-100t/s depending on context. Setting the threshold accordingly gives pretty good latency for e.g. tool calls.

On my setup, llama.cpp actually has faster offload-to-GPU prefill speed than sglang/kt-kernel, at least for normal-ish batch sizes that I encounter in practice. My sglang/kt-kernel layerwise-offload speed is a bit slower than yours, like 0.380s / layer, but in llama.cpp, I can routinely near-saturate my PCIe 5.0 x16 bandwidth, hitting like 50GiB/s. I'm not sure if it breaks even at some point when I hit batch sizes of like 32768 in sglang/kt-kernel compared to the 4096 I have set in llama.cpp for reasonable compute buffer sizes.

sglang/kt-kernel has excellent decode speed with true multi-NUMA tensor parallelism though. Also AMX support for really speedy prefill on models quanted to their bespoke format, but I kinda don't trust the quality of per-channel RTN int4 quant, and 8bit is rather fat and slows decode a lot.

The build/install quality of sg/kt leaves much to be desired, but once a working commit/library-build out/CUDA combo etc. is found the program itself is stable and runs w/o error for days or weeks.

Yeah a bunch of stuff broke with the updates for DSv4 lol, I think I finally have ol' Kimi K2.6 working again on latest commit sglang/kt-kernel.

...and even more off-topic (but still maybe interesting for NUMA users):

Meanwhile lvllm and lsglang appear to have matured a lot wrt. build/install quality. lvllm w/DSv4-Flash gives ~45-50t/s decode w/MTP on my setup, prefill ~1000t/s (by wall-clock measurement, the logs report values that are nonsensically high). There's high latency as the system is first running since tilelang continues to compile additional kernel variations for quite some time. Eventually this settles and the system behaves normally wrt. TTFT.

lsglang w/Kimi gives me incoherent/gibberish output, reason unknown.

Interesting, might be another backend for me to look into.

@Ph0rk0z

Ph0rk0z commented Jun 7, 2026

Copy link
Copy Markdown

fastllm had the best decode for me and I saw all my bandwidth. Not being an epyc user, I am in the shit position of putting everything on one CPU not helping. QQ89 with 24c is simply not enough. I also get some bottleneck from PCIE3 and one PLX per 2 GPUs. Need to try lvllm and lsglang with some mid size model like qwen-235b. QOL on these backends is terrible compared to IK so I take the path of least resistance.

@jukofyork

Copy link
Copy Markdown
Collaborator

Sadly I don't really know enough about CUDA or the workings of GGML, but I've had another idea for this that might work but at the cost of quite a lot of extra VRAM:

  1. Pre-allocate enough VRAM for a MoE layer's up+gate+down tensors (~9GB for Kimi at 4bits).
  2. During the first batch, store the pointers and lengths of all the MoE tensors (similar to my first version of this where I made a second copy in the huge map above). When you see the last triplet of tensors (ie: for the last layer), asynchronously preload the first triplet of tensors into the VRAM scratch buffer.
  3. During subsequent batches, do a VRAM to VRAM copy from the scratch buffer to the target buffer in ggml_backend_cuda_buffer_set_tensor and asynchronously preload the next triplet of tensors into the VRAM scratch buffer.

I doubt I will be able to try this as can't afford an extra 9GB of VRAM, but also because it is probably very brittle and the optimisation of skipping the final MoE tensors for all but the final token in a PP batch will add complexity, etc.

It may turn out that the extra cost in VRAM can be somewhat offset by using much smaller ubatch size.

@dbsanfte

Copy link
Copy Markdown
Author

I've released my inferencing engine called Llaminar. It solves the NUMA problem by using OpenMPI and should scale to large clusters over Infiniband although I lack the hardware to test it. Also supports CUDA and ROCm, with the fastest Mi50 kernels that I've seen.

https://github.com/Llaminar/llaminar

Feel free to have a play.

@Ph0rk0z

Ph0rk0z commented Jun 23, 2026

Copy link
Copy Markdown

I have avx512 but no vnni so stuck in /wait

@usrlocalben

usrlocalben commented Jul 15, 2026

Copy link
Copy Markdown

Sadly I don't really know enough about CUDA or the workings of GGML, but I've had another idea for this that might work but at the cost of quite a lot of extra VRAM:

  1. Pre-allocate enough VRAM for a MoE layer's up+gate+down tensors (~9GB for Kimi at 4bits).
  2. During the first batch, store the pointers and lengths of all the MoE tensors (similar to my first version of this where I made a second copy in the huge map above). When you see the last triplet of tensors (ie: for the last layer), asynchronously preload the first triplet of tensors into the VRAM scratch buffer.
  3. During subsequent batches, do a VRAM to VRAM copy from the scratch buffer to the target buffer in ggml_backend_cuda_buffer_set_tensor and asynchronously preload the next triplet of tensors into the VRAM scratch buffer.

I doubt I will be able to try this as can't afford an extra 9GB of VRAM, but also because it is probably very brittle and the optimisation of skipping the final MoE tensors for all but the final token in a PP batch will add complexity, etc.

It may turn out that the extra cost in VRAM can be somewhat offset by using much smaller ubatch size.

@jukofyork

After being reminded of the loss I revisited this.

Here is an implementation of the double-buffer with prefetch from NUMA shards that gives nearly the same perf as NPS0 pinned tensors.

I also tried an impl moving the buffers to VRAM, but the perf wasn't as great at first glance and quite expensive when that space can now be used as KV for 1M ctx models that weren't available earlier in this thread.

Example invocation:

MEMORY_CONCURRENCY_IN_NUMA_NODES=8
COMPUTE_POWER_IN_THREADS_PER_NUMA=6
M=/model/GLM-5.2/unsloth/UD-Q4_K_XL/GLM-5.2-UD-Q4_K_XL-00001-of-00011.gguf

N_THREADS=$[ $MEMORY_CONCURRENCY_IN_NUMA_NODES * $COMPUTE_POWER_IN_THREADS_PER_NUMA ]
N_BATCH=8192

GGML_CUDA_NO_PINNED=1 \
GGML_CUDA_EXPS_READAHEAD_THREADS=10 \
./ik_llama.cpp/build/bin/llama-server \
  --host 0.0.0.0 --port 4972 --webui llamacpp \
  --numa distribute \
  -t $N_THREADS \
  --cache-ram 200000 \
  -b $N_BATCH -ub $N_BATCH -amb 512 \
  -mla 1 --dsa -fidx -ctk f16 \
  -wgt 1 \
  -ngl 999 -ot exps=CPU \
  --jinja --parallel-tool-calls \
  --chat-template-kwargs '{"enable_thinking": true, "reasoning_effort": "max"}' \
  -c $[ 2**18 ] \
  --metrics \
  --alias GLM-5.2 \
  -m "$M"

(not shown: drop_caches, anyone reading this should know this protocol)

@jukofyork

Copy link
Copy Markdown
Collaborator

Nice! I'm away from home for the next week or so but will give this a try when I get back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Apple Metal https://en.wikipedia.org/wiki/Metal_(API) Ascend NPU issues specific to Ascend NPUs devops improvements to build systems and github actions examples ggml changes relating to the ggml tensor library for machine learning IBM zDNN issues specific to IBM zDNN Accelerator Nvidia GPU Issues specific to Nvidia GPUs OpenCL Issues specific to the OpenCL backend python python script changes SYCL https://en.wikipedia.org/wiki/SYCL - GPU programming language testing Everything test related Vulkan Issues specific to the Vulkan backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants