Skip to content

trace: event tracer for the RPC backend, rpc-server and llama-server - #191

Draft
danielhanchen wants to merge 8 commits into
feature/pipeline-groupsfrom
feature/rpc-trace
Draft

trace: event tracer for the RPC backend, rpc-server and llama-server#191
danielhanchen wants to merge 8 commits into
feature/pipeline-groupsfrom
feature/rpc-trace

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 5, 2026

Copy link
Copy Markdown
Member

What this is

A timing and profiling system for the RPC backend and llama-server, so that the bottlenecks of a
two node layer split are measured rather than inferred. It replaces the ad hoc pieces used so far
(RPC command counters in the client, per group timers in the server) with one tracer that both
nodes write to and one tool that merges them.

Based on feature/pipeline-groups.

Design

ggml/include/ggml-trace.h and ggml/src/ggml-trace.cpp live in ggml-base, so every binary that
takes part (libllama, llama-server, the RPC backend, ggml-rpc-server) can raise events without a
new dependency.

  • Off by default, and free when off. The tracer writes nothing unless GGML_RPC_TRACE=<path>
    is set, or ggml-rpc-server --trace <path>. Call sites read the exported flag directly:

    const int64_t t0 = ggml_trace_flag ? ggml_trace_time_us() : 0;

    so with tracing off the cost is one load and one branch; no clock is read, nothing is formatted
    and no allocation happens. There is no change to any output byte with the tracer off or on.

  • JSON lines. One header object per file, then one object per event, one file per process.
    A line is built in the calling thread and handed to the file under a mutex, and the file is
    flushed every 128 lines so that a process stopped with a signal does not lose its tail.

  • Clock alignment. A new RPC_CMD_TRACE_SYNC carries a four timestamp exchange (client sends
    at t1, peer stamps t2 on receive and t3 on reply, client stamps t4), done once per connection at
    connect time and only while tracing is on; the peer always answers it. The resulting offset is
    written into the client trace, and the merge tool uses it to put both nodes on one time line.
    Measured offsets on the pair were about 1.8 s between the two boot clocks, with round trips in
    the tens of microseconds.

What is recorded

RPC client, one record per command: command type, bytes sent and received, the tensor name or
graph uid it belongs to, the calling thread, the llama-server pipeline group (threads tag
themselves through a thread local), and timestamps at enqueue into the dispatcher, first byte
sent, last byte sent, our turn in the reply order, first byte of the reply, reply complete.

Scheduler, per split: the backend, the input count and the node count; and the staging path of
ggml_backend_tensor_copy broken into host allocation, read back and send. That is the
Spark 1 to CPU to Spark 2 hop of a layer split, visible as its own span.

rpc-server, one record per command served: receive start and end, execute start and end, reply
start and end, bytes each way, graph node count and device.

GPU. A backend submit only queues the work, so host timestamps around it say nothing about
when the kernels ran. The CUDA backend exports two entry points through the registry
(ggml_backend_cuda_trace_mark, ggml_backend_cuda_trace_poll), so ggml-base and the RPC backend
do not have to link against CUDA. They record CUDA events on the compute stream around
ggml_backend_graph_compute and report the completions on the host monotonic microsecond scale,
anchored by one event whose completion time on the host was measured once. Nothing ever waits on
the GPU: the completed spans are collected from points where the caller is idle anyway (the serve
loop between commands, the scheduler synchronize). The hooks are looked up per backend registry,
so a scheduler that runs over a CPU or RPC backend as well still gets the CUDA rows.

llama-server, per group and per iteration: batch build, submit (llama_decode),
llama_synchronize, post decode, sampling and result send, with slot counts. libllama, per
llama_decode: the scheduler split count, with the per split backend coming from the scheduler
events above.

Tools

scripts/rpc_trace/merge.py reads the files of both nodes, aligns them with the measured clock
offset and writes

  • a Chrome trace (chrome://tracing, or the Perfetto UI) with one row per node, thread and
    pipeline group, the phases of each RPC command as nested slices, and one row per GPU carrying
    the CUDA event timings, and
  • a text summary per decode step: local compute, transfer, peer compute, logits return, sampling,
    the idle fraction of each GPU, the longest stretch of the step in which neither GPU was busy,
    and the total idle time attributed to each host phase.

scripts/rpc_trace/cpu_check.sh is the CPU validation: two rpc-servers on the CPU backend and one
llama-server splitting layers over them, run once with the trace off and once with it on, with the
generated text compared. scripts/rpc_trace/gpu_trace.sh and scripts/rpc_trace/nonrpc_bracket.sh
run the cells below.

Overhead

Measured, not assumed: every configuration was run with the tracer off and on.

Layer split over two nodes, Qwen3.8-27B UD-Q4_K_XL, 32 concurrent, npp 128 / ntg 256,
--cache-ram 0, -c 16384, whole cell tok/s:

configuration trace off trace on
N=1 --device CUDA0,RPC0 95.91 96.21
N=1 --device RPC0,CUDA0 101.15 99.91
N=2 --device RPC0,CUDA0 138.96 141.62

That is +0.3, -1.2 and +1.9 percent, in both directions, so within run to run noise. The client
writes about 865 events per second (19 MB over a 170 s cell) and the peer about 60 per second.

Non-RPC and non-CUDA workloads

The tracer lives in ggml-base and adds two entry points to the CUDA backend's registry, so it is
on the path of workloads that never touch RPC. It costs them nothing.

Single GPU, no --rpc, llama-batched-bench -c 32768 -npp 512 -ntg 128 -npl 1,8,32 on the same
model, run base / new / base / new-with-the-trace-on, prompt and generation tok/s:

pass npl 1 npl 8 npl 32
base 823.52, 11.72 829.56, 59.98 830.42, 114.97
new 821.85, 11.68 826.79, 58.49 679.02, 111.64
base (repeat) 675.46, 11.47 679.68, 56.18 680.51, 110.75
new, trace on 669.13, 11.34 678.69, 55.87 678.43, 110.57

The node dropped to a lower clock state between the second and the third pass (prompt throughput
falls from about 828 to about 679 for both builds), so the comparisons that hold are the ones
inside one clock state: new against base at npl 1 and 8 is -0.2 and -0.3 percent on prompt, new
against the base repeat at npl 32 is -0.2 percent on prompt and +0.8 on generation, and new with
the trace on against the base repeat is -0.3 and -0.2 percent. Nothing outside noise.

Bit exactness, five greedy prompts, 48 tokens each:

  • single GPU, no RPC: md5 74926c4ef135f3cc89ad20cd5ec7e445 for the base, for this branch with
    the trace off, and for this branch with the trace on;
  • CPU only build, -DGGML_CUDA=OFF -DGGML_RPC=OFF: md5 177dc61e0703eba3bdaf7bf1131f0458 for
    the same three.

Build configurations checked: -DGGML_RPC=OFF -DGGML_CUDA=ON and -DGGML_CUDA=OFF -DGGML_RPC=OFF both build clean, and the CPU only build still produces a usable trace of the
llama and scheduler events with no GPU rows.

Other backends. Vulkan has no get_proc_address at all and the registry returns NULL for that
case; Metal and SYCL fall through to NULL for a name they do not know. ggml_trace_gpu_begin
therefore gets no hook, returns 0 and records nothing, and no other line of those backends is
touched. ggml-cuda.cu is also the HIP and MUSA source, and the vendor headers did not map
cudaEventCreate, cudaEventQuery or cudaEventElapsedTime, so those two builds would not have
compiled; the three defines are added.

The three timelines

Per decode step, in milliseconds, from the traces. localGPU and peerGPU come from CUDA events
on the compute stream of each node, so they are what the GPUs did, not what the host queued.
idle both is the part of the step in which neither GPU was busy.

configuration step batch build submit synchronize post decode sampling send localGPU peerGPU idle both
N=1 CUDA0,RPC0 325.6 10.1 303.0 0.0 12.2 6.9 5.1 150.0 149.7 27.1
N=1 RPC0,CUDA0 312.3 10.2 159.2 129.7 12.8 6.9 5.7 140.0 154.9 24.3
N=2 RPC0,CUDA0 group 0 219.5 6.7 107.2 94.4 8.4 6.3 2.0 195.9 199.4 5.9
N=2 RPC0,CUDA0 group 1 221.4 8.6 110.4 92.7 6.7 3.8 2.8 194.6 199.3 6.6

GPU busy over the whole cell, and RPC bytes per step:

configuration local GPU busy peer GPU busy bytes out bytes in
N=1 CUDA0,RPC0 46.1% 46.0% 1035 kB 39441 kB
N=1 RPC0,CUDA0 44.8% 49.6% 1038 kB 11509 kB
N=2 RPC0,CUDA0 87.8% 89.7% 1219 kB per group 5445 kB per group

What the traces say.

  • N=1 with the output layer on the peer. 39.4 MB comes back over the link every step: the F32
    logits of 32 rows over a 248320 token vocabulary. llama_decode does not return until the
    whole step is finished (submit 303 ms, synchronize 0), because the blocking GET_TENSOR of the
    logits sits inside it. The two GPUs never overlap: 150.0 and 149.7 ms of GPU work inside a
    325.6 ms step, each of them idle through the other's stage. Named bottleneck: the serial chain
    itself, with the logits return as the transport cost that pays for nothing.
  • N=1 with the output layer on the local node. The logits stay local and only 11.5 MB comes
    back, and the step now splits into submit 159 ms and synchronize 130 ms. The step barely moves
    (312.3 against 325.6) because the stages still alternate: 140.0 and 154.9 ms of GPU work in a
    312.3 ms step. Named bottleneck: still the serial chain. The staged copy of the hidden state
    through a host buffer (ggml_backend_tensor_copy, since the RPC backend has no
    cpy_tensor_async) is 148.4 ms per step here, which is the span an asynchronous device to
    device path would attack; that number includes the blocking wait for the peer's graph, so it is
    an upper bound on the copy itself.
  • N=2 with the output layer on the local node. 195.9 and 199.4 ms of GPU work in a 220 ms
    step, both GPUs at 88 to 90 percent, and only 5.9 to 6.6 ms per step in which neither GPU is
    busy against 24 to 27 ms with one context. Named bottleneck: GPU compute. The host path is off
    the critical path because one group runs it under the other group's GPU work.
  • Where the host stalls both GPUs, per step: with one context, post decode 12.1 to 12.8 ms
    (sampling and streaming) and batch build 8.3 to 9.5 ms; with two groups, batch build 2.6 to
    3.3 ms and post decode 0.1 to 0.3 ms. The single longest idle gap in every cell is a prefill
    step's batch build, 0.9 to 1.3 s, which is a time to first token cost and not a decode cost.

Validation

CPU harness (scripts/rpc_trace/cpu_check.sh, two local CPU rpc-servers, three greedy prompts):
the generated text is byte identical with the trace off and on, the trace files parse, and the
measured clock offsets between the local processes are 0 and 1 us with a 14 to 16 us round trip.

…ma-server

Adds one coherent timing system in place of the ad hoc counters, so the cost of a two node
layer split is measured rather than inferred.

ggml/include/ggml-trace.h, ggml/src/ggml-trace.cpp
  JSON line writer, off unless GGML_RPC_TRACE names a file. Call sites read the exported flag
  directly, so with tracing off the cost is one load and one branch and no clock is read.
  Also holds the GPU span helper: the timing hooks are resolved through the backend registry,
  so ggml-base does not link against any GPU runtime.

ggml-cuda
  two entry points exported through the registry: record a CUDA event on the compute stream,
  and collect the events that have completed. The completions are reported on the host
  monotonic scale through one anchor event whose completion time was measured once, and nothing
  ever waits on the GPU.

ggml-rpc
  client: one record per command with the bytes each way, the tensor or graph it belongs to,
  the thread, the llama-server group, and timestamps at enqueue, first byte sent, last byte
  sent, our turn in the reply order, first byte of the reply and reply complete.
  server: one record per command served with receive, execute and reply timestamps, the graph
  node count and payload size, and CUDA event timestamps around ggml_backend_graph_compute.
  new RPC_CMD_TRACE_SYNC: a four timestamp exchange at connect time, written into the client
  trace so the two nodes can be put on one time line. It is only sent while tracing is on.

ggml-backend
  per scheduler split: backend, input count, node count and a GPU span around the submit;
  the staging path of ggml_backend_tensor_copy broken into host allocation, read back and send,
  which is the Spark 1 to CPU to Spark 2 cost of the split.

llama-server and libllama
  per group and per iteration: batch build, submit, synchronize, post decode, sampling and
  result send with slot counts; per llama_decode the scheduler split count.

scripts/rpc_trace/merge.py
  aligns the files with the measured clock offset and emits a Chrome trace with one row per
  node, thread and group plus a row per GPU, and a per step summary with the idle fraction of
  each GPU and the biggest idle gap.

scripts/rpc_trace/cpu_check.sh
  two rpc-servers on the CPU backend, run with the trace off and on, output compared.
…erge by time

A scheduler runs over several backends and only some of them offer the timing hooks. The probe
was cached once for the process, so a split whose first backend was the CPU or the RPC backend
disabled the GPU rows for the whole run, and a backend could have been handed to another
registry's mark function. The answer is now kept per registry.

merge.py walked every event of the file for every phase of every step. The events are indexed by
start time and looked up with a bisect instead.
ggml-cuda.cu is also the HIP and MUSA source, and the GPU timing hook uses cudaEventCreate,
cudaEventQuery and cudaEventElapsedTime, which the vendor headers did not map yet. Adds the
three defines and a single GPU bracket script for the non RPC proof.
…r properly

A traced process is normally stopped with a signal at the end of a run, and the tail of the
stdio buffer was lost with it. The file is now flushed every 128 lines.

The bench script launched the peer server through setsid, so the pid it captured was setsid's
and the server, its child, survived every kill. The next cell then found the port taken, logged
'Failed to create server socket' and silently talked to the previous server, which is how a
whole set of peer traces came back with nothing but a header. The launch no longer goes through
setsid, ssh is given -n, the bind is checked, the port is waited on before and after each cell,
and any leftover server on our port is matched with pgrep -x on the binary name and the port in
/proc rather than with a pattern that would also match the remote shell.
…, drop the run artifacts

The summary named only the single longest stretch of a step in which neither GPU was busy, which
is always a prefill batch build and says nothing about the decode steps. It now also reports the
total idle time attributed to each host phase, per step.

Also removes the bench output and the driver script that were committed by mistake.
…ature/rpc-trace

# Conflicts:
#	tools/server/server-context.cpp
@danielhanchen

Copy link
Copy Markdown
Member Author

Added scripts/rpc_trace/device_idle.py to this PR, because the tracer answered the question it was built for and then could not answer the next one.

merge.py says where a decode step goes, per pipeline group. It does not say whose fault a given idle interval on one device is, and that distinction decides what a fix would have to look like: idle while the other device computes is a scheduling problem, idle while neither device computes is a host problem, and they have different fixes. The new tool intersects one device's idle with the other device's busy time to split exactly that, reusing merge.py's union/clip/gaps/Index.covered primitives rather than duplicating them.

It also splits the cell by phase, and that turned out to be the whole answer. Time counts as prefill when any group is inside an iteration that submitted more tokens than it had slots. This has to be a SET of intervals and not a range: a serving cell interleaves prompt batches with decode for its whole length, so "everything before the last prompt iteration" puts most of the decode inside the prefill phase and reports nothing.

What it found on a 27B layer split across two DGX Sparks at 128 concurrent rows with two pipeline groups, 313 s window, 557k events, both nodes clock-pinned:

                        busy     idle   idle while the     idle with
                                        other computes   neither computing
  local                89.16%   10.84       9.71              1.13
  peer                 91.55%    8.45       7.31              1.13

  prompt-batch iterations, 18.3% of the window:  peer busy 68.81%
  decode-only time,        81.6% of the window:  peer busy 96.68%

Nine tenths of the remaining idle is one device waiting on the other, and two thirds of it lives in the 18 percent of the window that carries a prompt batch. Median idle stretch on the peer is 6.1 ms in decode and 55.2 ms in a prompt iteration.

Non-RPC and other-path proof. This commit adds one new file under scripts/ and changes nothing else: no source file, no header, no CMake target, no build option, no runtime code path. git show --stat is a single added Python file. It cannot affect NVIDIA, AMD or CPU-only builds on Linux, Windows, WSL or Mac, cannot affect Vulkan, Metal, SYCL, HIP or MUSA, cannot affect KV caching or prefix caching, and cannot affect any workload, RPC or otherwise, because nothing in the library or the tools ever reads it. It is an offline reader of the JSONL files this PR already produces, and it runs on a laptop against a trace copied off the machine.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant