Skip to main content

Valkey Over RDMA: Multithread Tests (New Test Framework)

Author
quanye
Systems software: OS, networking, distributed systems.
Table of Contents

I treat this as a new feature: use the libvalkey client to simulate load, with multithread support.

Test logic for this change
#

This is a new feature; we need a new PR.

Under tests/rdma, add libvalkey-test.c and use libvalkey’s client APIs for testing.

First study the existing test logic.

tests/rdma is not a conventional unit-test layout:

Directory layout:

File Role
run.py Main orchestrator: build, find RDMA NIC, start valkey-server, run rdma-test, cleanup
rdma_env.py As root, setup/cleanup soft RDMA (RXE module, rdma link add)
rdma-test.c C test client: hand-rolled RDMA CM/verbs, sends RESP
Makefile Builds rdma-test (links librdmacm, libibverbs)

Roughly a smoke test — pizhenwei wrote a small RDMA client by hand.

But IO multithread is clearly not enabled.

Build once the same way. AI implemented it first; we then reviewed how:

make -j$(nproc) BUILD_RDMA=yes USE_FAST_FLOAT=yes
make -C tests/rdma

Broadly: use libvalkey client APIs to approximate this stress command, then adjust the build files. The code volume is large and needs real understanding.

sudo prlimit --memlock=unlimited --pid $$
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 500 -P 256 -n 50000000 -t set,get --rdma

We tested and changed a lot on the original branch with many commits, but I want this test alone in one PR: drop the partial testing from that branch so far, then replay on unstable to prove the bug.

You can tune parameters and reproduce yourself:

~/Project/valkey rdma-libvalkey-test* ❯ sudo ./runtest-rdma --install-rxe \
  --io-threads 8 \
  -c 500 -P 512 -n 1000000 --timeout 600

Under these conditions you will hit an assertion failure.

So parameters need a slight bump — but not too high, or runtime / OOM becomes a problem. Overall some knobs went up.

What does libvalkey-test.c do?
#

Think of it as: use the official libvalkey client to reproduce valkey-benchmark-style pipeline stress over RDMA.

What is this test verifying?
#

Compared with rdma-test.c in the same directory:

Capability / file rdma-test libvalkey-test
Client stack Hand-written RDMA CM / CQ / QP libvalkey (deps/libvalkey)
Protocol Hand-built RESP, hand-read RDMA buffers libvalkey formats RESP + RDMA transport
Concurrency 4 threads, 1 connection each 16 workers × multiple connections per worker
Mode One-by-one SET/GET Pipeline: append a batch, flush, drain
Goal Functional smoke (PING/SET/GET/BGSAVE) Stability under RDMA + IO threads + high pipeline

The bug you are fixing (cmd_queue.len == 0 assert) needs both:

  1. Many connections with inflight pipelined commands
  2. Server IO threads on the read path (run.py enables this only in the second phase)

This program is the client-side load generator designed for that.

Overall architecture
#

flowchart TB
    subgraph main["main()"]
        init[valkeyInitiateRdma]
        spawn[创建 16 个 pthread]
    end

    subgraph worker["worker_main() × 16"]
        conn[每 worker 建 N 个 valkeyContext RDMA 连接]
        set[run_phase SET]
        get[run_phase GET]
    end

    subgraph per_conn["每个 client_state / valkeyContext"]
        obuf[obuf: 待发 RESP 命令队列]
        reader[reader: 已收 RESP 解析队列]
    end

    main --> worker
    worker --> per_conn
    obuf -->|valkeyBufferWrite → RDMA| server[valkey-server RDMA]
    server -->|RDMA 响应| reader

Basic idea: many threads, each managing many connections.

Note the thread vs connection split:

  for (int i = 0; i < cfg.threads; i++) {
      workers[i].cfg = & cfg; // 设置当前worker的配置
      workers[i].thread_id = i; // 这是第几个线程
      workers[i].first_client = (cfg.clients * i) / cfg.threads; // 本线程对应的第一条连接
      workers[i].last_client = (cfg.clients * (i + 1)) / cfg.threads; // 本线程对应的最后一条连接
      if (pthread_create( & threads[i], NULL, worker_main, & workers[i])) { // 根据我们刚才的设置来创建这个线程,然后进行处理
          fprintf(stderr, "failed to create thread %d\n", i);
          ret = 1;
          cfg.threads = i;
          break;
      }
  }

Distribute total request count:

static long long requests_for_client(const test_config *cfg, int client_id) {
  long long base = cfg->requests / cfg->clients;
  long long extra = client_id < (cfg->requests % cfg->clients) ? 1 : 0;
  return base + extra;
}

When not divisible, the first requests % clients clients get one extra, so the sum equals cfg->requests exactly.

Core data structures
#

typedef struct test_config { // 当前test的配置 全局参数,所有worker read only
  const char *host;
  int port;
  int threads;
  int clients;
  int pipeline;
  long long requests;
  size_t datasize;
  char *value;
} test_config;

typedef struct worker_config { // 当前工作线程worker的配置
  const test_config *cfg;
  int thread_id;
  int first_client;
  int last_client;
} worker_config; // 这里会记录连接线程的区间 以及线程本身的配置

typedef struct client_state { // 一个连接的状态
  int client_id; // 客户端ID
  long long requests; // 一个连接处理的请求数量
  long long processed;
  int pending;
  valkeyContext *context;
} client_state;
Field Meaning
test_config Global params; all workers read-only
worker_config Client range for this pthread [first_client, last_client)
client_state.processed Completed SET (or GET) count on this connection
client_state.pending Commands appended in the current pipeline batch but not yet drained
client_state.context libvalkey connection; has obuf (write buffer) and reader (parse)

Also use distinct keys per connection to avoid contention:

static void make_key(char *buf, size_t len, int client_id, long long seq) {
  snprintf(buf, len, "rdma:%04d:%012lld", client_id, seq);
}

Lifecycle of one worker
#

static void * worker_main(void * arg) {
    worker_config * worker = arg;
    const test_config * cfg = worker - > cfg;
    int state_count = worker - > last_client - worker - > first_client;
    client_state * states;
    int ret = 1;

    states = calloc(state_count, sizeof( * states));
    if (!states) {
        fprintf(stderr, "thread %d failed to allocate client states\n",
            worker - > thread_id);
        return (void * )(long) 1;
    }

    for (int i = 0; i < state_count; i++) {
        int client_id = worker - > first_client + i;

        states[i].client_id = client_id;
        states[i].requests = requests_for_client(cfg, client_id);
        // 此处就尝试建立连接了
        states[i].context = connect_rdma(cfg, client_id);
        if (!states[i].context)
            goto cleanup;
    }

    if (run_phase(states, state_count, cfg, 0) != 0)
        goto cleanup;
    for (int i = 0; i < state_count; i++)
        states[i].processed = 0;
    if (run_phase(states, state_count, cfg, 1) != 0)
        goto cleanup;

    printf("Valkey Over RDMA libvalkey thread[%d] clients %d-%d SET/GET %lld "
        "requests [OK]\n",
        worker - > thread_id, worker - > first_client, worker - > last_client - 1,
        cfg - > requests);
    ret = 0;

    cleanup:
        for (int i = 0; i < state_count; i++) {
            if (states[i].context)
                valkeyFree(states[i].context);
        }
    free(states);

    return (void * )(long) ret;
}

After that, merge the two branches and test on a temporary branch.

Reproducing in CI
#

Because this is a standalone test branch, we need CI to reproduce the failure to prove correctness.

test:

sudo ./runtest-rdma --install-rxe
  
Valkey Over RDMA build test programs [OK]
Valkey Over RDMA test detect existing RDMA device [FAILED]

It says no device detected.

But in show-kernel-log:

0s
Run sudo dmesg -c
[  308.887784] rdma_rxe: loading out-of-tree module taints kernel.
[  308.887792] rdma_rxe: module verification failed: signature and/or required key missing - tainting kernel
[  308.890138] rdma_rxe: loaded
[  308.896386] infiniband rxe_eth0: set active
[  308.896390] infiniband rxe_eth0: added eth0
[  309.206987] rdma_rxe: unloaded

We can see the device was loaded — so why?

Ultimately a pile of Python script issues; painful, but we fixed them so terminal error logs show on the CI page, and we correctly reproduced the bug. Good.

Abstracting the two test files
#

Reminder: refactoring / abstraction is software’s root craft.

First wave of abstraction:

  1. Unify requests with minkeys/maxkeys — both sides use minkeys/maxkeys with sensible defaults so the bug can still be triggered
  2. Fill KV pairs with the same logic; after SET, GET must validate (compare) so both tests align. Pay attention to what that means.

Those three points are the main temporary targets.

Large refactors are hard — you don’t know where to start. Another learning point: everything is tangled; moving any piece hurts.

Step by step
#

  1. First extract shared structs; both tests use them; fields one side lacks get DEFAULT. Expected: with no CLI args, each test keeps its own defaults (CI behavior); with CLI args, both share the same parameters.

Example expected defaults:

  ┌──────────┬───────────────────────┬────────────────┐
  │   参数   │       rdma-test       │ libvalkey-test │
  ├──────────┼───────────────────────┼────────────────┤
  │ port     │ 63796379  ├──────────┼───────────────────────┼────────────────┤
  │ threads  │ 0(单线程 main 模式) │ 16  ├──────────┼───────────────────────┼────────────────┤
  │ clients  │ —(不用)             │ 128  ├──────────┼───────────────────────┼────────────────┤
  │ pipeline │ —                     │ 384  ├──────────┼───────────────────────┼────────────────┤
  │ requests │ —                     │ 200000  ├──────────┼───────────────────────┼────────────────┤
  │ minkeys  │ 128                   │ —              │
  ├──────────┼───────────────────────┼────────────────┤
  │ maxkeys  │ 8192                  │ —              │
  ├──────────┼───────────────────────┼────────────────┤
  │ datasize │ 1024256  └──────────┴───────────────────────┴────────────────┘

Looks fine. The two tests now share parameters; details can change later.

  1. Next drop the requests parameter; randomize each client’s request count within minkeys/maxkeys. Small change; done. Still stably reproduces the multithread bug.

  2. Next unify test logic: true random keys/values, then compare on GET.