You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 Systems — nsys 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.
Benchmarks — BenchmarkRunner 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.
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.
Each execution ends with a blocking D2H, then flush(), then waitOn() — 3 cuStreamSynchronize per execution, two against a stream that is already empty.
With -Dtornado.profiler=True, every transfer and launch did resolveEvent → waitForEvents → getElapsedTimeinline, 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.
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.
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 on — LOG_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.
[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:
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.
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.
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.
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.
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:
Method
stackdepth=192,-XX:+DebugNonSafepoints; samples phase-split by filtering stacks (decode vs start-up).nsys profile -t cuda --sample=none --cpuctxsw=none, thennsys stats --report cuda_api_sumfor per-iteration API call counts, used as ground truth for what the driver actually did.BenchmarkRunner 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 16777216andnbody 500 16384as GPU-bound controls; GPULlama3.java on Qwen1.5-MoE-A2.7B-Chat Q8_0 as the real end-to-end workload.What the profiles found
On
develop, asaxpyover 512 floats spent 17.46 µs perexecute()while the GPU did 3.31 µs of work. Per iteration the runtime issued 4 stream operations, 12 event calls and 3cuStreamSynchronizeto move 2 KB and run a 1.1 µs kernel. Four separate causes, each with its own PR:cuMemcpyHtoDAsync+ an event pair, not the bytes.flush(), thenwaitOn()— 3cuStreamSynchronizeper execution, two against a stream that is already empty.-Dtornado.profiler=True, every transfer and launch didresolveEvent → waitForEvents → getElapsedTimeinline, so the host waited for each operation before enqueuing the next. 51.9% of wall time insidecuEventSynchronize. Turning the profiler on made a short graph 3.3x slower, and the numbers it reported were wrong:copyInAvg = 12.9 µsfor a copy nsys measures at 0.65 µs.TornadoVMInterpreter.initWaitEventList()clears each allocated wait-list row in full at the top of everyexecute(). A row istornado.max.eventsentries, default 32768 (128 KB). On a Qwen1.5-MoE decode loop with dependency tracking on,Arrays.fillunderinitWaitEventListwas 51% of decode host CPU.A fifth PR came out of the same campaign as a correctness prerequisite for trusting any of these numbers:
LOG_CUDA_AND_VALIDATEis 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=maxloads but cannot launch, andnbody 500 16384cheerfully reportedmedian(ns)=4.89e+04against a real 1.72 ms — a 35x "speed-up" produced entirely by no kernel running.Combined measurements
Three builds, same machine, same session:
develop@4d90e2e9fdevelopsaxpy 100000 512, per-execute()mediansaxpy 100000 512, profiler ONtornado.vm.deps=False)-Dtornado.vm.deps=TrueMoE decode = Qwen1.5-MoE-A2.7B-Chat Q8_0, 128 generated tokens, greedy-ish (
--temperature 0.1 --seedfixed), 2 runs per cell.Driver traffic, nsys, 20,000 iterations of
saxpy 512:developH2D 24 BinstancescuStreamSynchronizecallscuEventCreate/Record/DestroyAcross the small-size benchmark sweep the win tracks how dispatch-dominated the graph is, and nothing regresses:
developsaxpystencilblackscholesnbodydftblurFilter 3000 256(3 tasks)GPU-bound controls — unaffected, as intended:
developsaxpy 2000 16777216nbody 500 16384PRs
cuStreamSynchronizeper execution to 1; 4.2% once #1022 landscuEventSynchronize51.9% to 1.1% of wall; reportedcopyInAvg~3x closer to nsys ground truth-Dtornado.max.events=1024workaroundAll 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 --quickPasson the CUDA backend against a same-machinedevelopbaseline, 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=Trueacross 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:checkclean 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:CUDACommandQueue.readArrayFromDeviceOffHeap— the blocking device-to-host read-back, i.e. genuine device waitclEnqueueNDRangeKernelwriteArrayToDeviceCandidate follow-ups, roughly in value order:
cuEventRecordcosts 615 ns withCU_EVENT_DEFAULTagainst 137 ns withCU_EVENT_DISABLE_TIMING, six per iteration, and timing events stretch the terminalcuStreamSynchronizefrom 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.tornado.max.events(32768 ints = 128 KB) on first write. Growing rows on demand would cut the allocation too.tornado.eventpool.maxwaiteventsvstornado.max.events. Two separate knobs with confusingly similar names and very different cost profiles; only the former is exposed by front-ends such asllama-tornado. Worth consolidating or at least documenting.clBuildProgram, 38%cuMemHostRegister. Separate chapter, but the larger absolute number for short runs.Reproduction