Skip to content

[perf] Host-side runtime overhead: where the time actually goes, the fixes, and what is left #1028

Description

@mikepapadim

Context

For small and medium task graphs, and for anything that runs many short executions per iteration (LLM decode, filters, iterative solvers), TornadoVM's wall-clock is dominated by host-side per-operation cost, not by GPU work. This issue collects the profiling campaign that quantified that cost, the PRs that remove it, and what is left.

A result worth stating up front, because it redirects where optimisation effort belongs:

The Java bytecode interpreter and its data structures account for ~0.9% of wall time on a dispatch-bound graph (118 jdk.ExecutionSample vs 13,545 jdk.NativeMethodSample, and most of the Java samples are the benchmark harness's own Arrays.sort). The cost is in the per-operation CUDA dispatch sequence and runtime bookkeeping, not in interpreting bytecodes.

Method

  • JFR — 1 ms sampling, stackdepth=192, -XX:+DebugNonSafepoints; samples phase-split by filtering stacks (decode vs start-up).
  • Nsight Systemsnsys profile -t cuda --sample=none --cpuctxsw=none, then nsys stats --report cuda_api_sum for per-iteration API call counts, used as ground truth for what the driver actually did.
  • BenchmarksBenchmarkRunner saxpy 100000 512 (dispatch-bound: one kernel, one H2D, one blocking D2H, 2 KB moved, 1.1 µs kernel) for per-execute() medians; saxpy 2000 16777216 and nbody 500 16384 as GPU-bound controls; GPULlama3.java on Qwen1.5-MoE-A2.7B-Chat Q8_0 as the real end-to-end workload.
  • Hardware — NVIDIA RTX 4090, Ubuntu 24.04, JDK 21.0.2, CUDA backend.

What the profiles found

On develop, a saxpy over 512 floats spent 17.46 µs per execute() while the GPU did 3.31 µs of work. Per iteration the runtime issued 4 stream operations, 12 event calls and 3 cuStreamSynchronize to move 2 KB and run a 1.1 µs kernel. Four separate causes, each with its own PR:

# Finding PR
1 The 24-byte kernel-argument stack frame is re-uploaded on every launch, even when identical across iterations. nsys puts that copy at 2.00 µs — the same cost as the 2 KB user-data copy in the same graph, because the price is the JNI round trip + cuMemcpyHtoDAsync + an event pair, not the bytes. #1022
2 Each execution ends with a blocking D2H, then flush(), then waitOn()3 cuStreamSynchronize per execution, two against a stream that is already empty. #1023
3 With -Dtornado.profiler=True, every transfer and launch did resolveEvent → waitForEvents → getElapsedTime inline, so the host waited for each operation before enqueuing the next. 51.9% of wall time inside cuEventSynchronize. Turning the profiler on made a short graph 3.3x slower, and the numbers it reported were wrong: copyInAvg = 12.9 µs for a copy nsys measures at 0.65 µs. #1024
4 TornadoVMInterpreter.initWaitEventList() clears each allocated wait-list row in full at the top of every execute(). A row is tornado.max.events entries, default 32768 (128 KB). On a Qwen1.5-MoE decode loop with dependency tracking on, Arrays.fill under initWaitEventList was 51% of decode host CPU. #1027

A fifth PR came out of the same campaign as a correctness prerequisite for trusting any of these numbers:

# Finding PR
5 When a CUDA driver call fails, the JNI prints the error and carries onLOG_CUDA_AND_VALIDATE is a logging macro despite the name. Java never learns, the read-back copies stale buffer contents, and the run reports success. Found while screening NVRTC flags: a cubin built with --Ofast-compile=max loads but cannot launch, and nbody 500 16384 cheerfully reported median(ns)=4.89e+04 against a real 1.72 ms — a 35x "speed-up" produced entirely by no kernel running. #1025

Combined measurements

Three builds, same machine, same session:

Workload A: develop B: + dispatch/profiler PRs C: + wait-list PR
saxpy 100000 512, per-execute() median 17.51 / 17.46 / 17.47 µs 8.59 / 8.44 / 8.41 µs (2.08x) 8.58 / 8.44 / 8.40 µs
saxpy 100000 512, profiler ON 57.03 / 56.39 µs 32.65 / 32.66 µs (1.73x) 32.60 / 32.68 µs
MoE decode, default (tornado.vm.deps=False) 70.99 / 70.51 tok/s 72.56 / 72.32 tok/s 72.15 / 72.46 tok/s
MoE decode, -Dtornado.vm.deps=True 45.68 / 46.60 tok/s 46.19 / 47.32 tok/s 72.34 / 72.49 tok/s (1.57x)

MoE decode = Qwen1.5-MoE-A2.7B-Chat Q8_0, 128 generated tokens, greedy-ish (--temperature 0.1 --seed fixed), 2 runs per cell.

Driver traffic, nsys, 20,000 iterations of saxpy 512:

develop with #1022 + #1023
NVTX H2D 24 B instances 20,000 (2.00 µs avg) 1
cuStreamSynchronize calls 60,000 (3/iter) 20,000 (1/iter)
cuEventCreate / Record / Destroy 4 each per iter 3 each per iter

Across the small-size benchmark sweep the win tracks how dispatch-dominated the graph is, and nothing regresses:

benchmark (size 512) develop with the stack speed-up
saxpy 18.33 µs 9.38 µs 1.95x
stencil 17.24 µs 13.24 µs 1.30x
blackscholes 22.56 µs 20.39 µs 1.11x
nbody 40.22 µs 39.22 µs 1.03x
dft 95.83 µs 94.18 µs 1.02x
blurFilter 3000 256 (3 tasks) 266.59 µs 262.56 µs 1.015x

GPU-bound controls — unaffected, as intended:

workload develop build C
saxpy 2000 16777216 7.542 / 7.530 / 7.518 ms 7.558 / 7.554 ms
nbody 500 16384 1.7222 / 1.7239 / 1.7260 ms 1.7178 / 1.7184 ms

PRs

PR Title What it buys
#1022 [cuda] Skip the per-launch kernel stack-frame upload when the device copy is unchanged 2.03x on short task graphs; 20,000 H2D copies per 20,000 iterations to 1
#1023 [cuda] Skip stream synchronisation when nothing has been enqueued since the last drain 3 cuStreamSynchronize per execution to 1; 4.2% once #1022 lands
#1024 [profiler] Harvest device timestamps after the stream drains instead of blocking on every operation profiler-on cost -34% standalone (-43% stacked); cuEventSynchronize 51.9% to 1.1% of wall; reported copyInAvg ~3x closer to nsys ground truth
#1025 [cuda] Propagate CUDA driver failures to Java instead of only logging them Correctness: failed launches and copies no longer complete "successfully" with stale results
#1027 [runtime] Clear only the written prefix of each wait-list row between executions 1.57x on MoE decode with dependency tracking on; removes the need for the -Dtornado.max.events=1024 workaround

All five are independent and separately reviewable. #1023 is measured both standalone and stacked on #1022 because its value only shows once the dominant per-launch cost is gone.

Testing

Every PR was checked with tornado-test --quickPass on the CUDA backend against a same-machine develop baseline, with identical per-class results (all failures pre-existing/whitelisted: Metal-only simdgroup tests, multi-backend virtualization tests, half-float vector types). #1027 was additionally run with -Dtornado.vm.deps=True across the whole suite, again with identical results. CUDA-graph capture paths (TestCuBlas, TestCuFft, TestExecutor) were exercised specifically for #1022 and #1023, since both touch state that must survive stream capture. ./mvnw checkstyle:check clean throughout.

What is left

JFR on build C (saxpy 200000 512, now 7.7 µs/execute()) shows the remaining host time is no longer bookkeeping:

  • 65% CUDACommandQueue.readArrayFromDeviceOffHeap — the blocking device-to-host read-back, i.e. genuine device wait
  • 14% clEnqueueNDRangeKernel
  • 8% writeArrayToDevice
  • Java-side samples: 2, against 63 native samples

Candidate follow-ups, roughly in value order:

  1. Non-blocking read-back / deferred output materialisation. The terminal blocking D2H is now the single largest host cost on dispatch-bound graphs. Anything that lets the host enqueue the next execution before the previous read-back completes attacks it directly.
  2. Remaining profiler overhead (~2.2x versus profiler-off). It is now the timing events themselves, not the deferral: cuEventRecord costs 615 ns with CU_EVENT_DEFAULT against 137 ns with CU_EVENT_DISABLE_TIMING, six per iteration, and timing events stretch the terminal cuStreamSynchronize from 3.0 µs to 14.0 µs. Reusing operation N-1's end event as operation N's start would halve it but is incompatible with deferred harvesting; an opt-in timing granularity (kernels only) is the viable route.
  3. Wait-list row sizing. [runtime] Clear only the written prefix of each wait-list row between executions (1.57x on dependency-tracked decode) #1027 removes the clear cost, but a row is still allocated at tornado.max.events (32768 ints = 128 KB) on first write. Growing rows on demand would cut the allocation too.
  4. tornado.eventpool.maxwaitevents vs tornado.max.events. Two separate knobs with confusingly similar names and very different cost profiles; only the former is exposed by front-ends such as llama-tornado. Worth consolidating or at least documenting.
  5. Start-up, not steady state. On the MoE run, start-up dominates whole-run wall (18 s total, decode only ~3.9 s): 47% NVRTC clBuildProgram, 38% cuMemHostRegister. Separate chapter, but the larger absolute number for short runs.

Reproduction

# dispatch-bound per-execute median
tornado --jvm="-Dtornado.benchmarks.skipserial=True" \
  -m tornado.benchmarks/uk.ac.manchester.tornado.benchmarks.BenchmarkRunner \
  --params="saxpy 100000 512"

# same, profiler on
tornado --jvm="-Dtornado.benchmarks.skipserial=True -Dtornado.profiler=True" \
  -m tornado.benchmarks/uk.ac.manchester.tornado.benchmarks.BenchmarkRunner \
  --params="saxpy 100000 512"

# driver call counts
nsys profile -t cuda --sample=none --cpuctxsw=none -o saxpy512 \
  tornado --jvm="-Dtornado.benchmarks.skipserial=True" \
  -m tornado.benchmarks/uk.ac.manchester.tornado.benchmarks.BenchmarkRunner \
  --params="saxpy 20000 512"
nsys stats --report cuda_api_sum saxpy512.nsys-rep

Activity

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

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions