The 8 GB Lab Book · Chapter 9

Rust as host, CUDA as device

Board
Jetson Orin Nano Super
Mode
15W
Setup
CUDA 12.6 · rustc 1.98
Summary
Same fused kernel, two hosts, a pageable copy inside the timed loop. Rust's tight-loop p50 was 967 us against 1777 us for Python. In the paced 30 Hz loop the same calls took 4284 us in Rust against 2327 us in Python. Rust's worst tick was 1168 us late against 40 us. The Rust host reached its first kernel in 94 ms against 294 ms. Three reruns on 2026-10-04: tight p50 962 to 995 us Rust against 1,593 to 1,660 Python; paced work 4,102 to 4,295 against 2,394 to 2,418 us, 1.7x to 1.8x; Rust's worst tick 0.5, 0.5 and 31 us, so the 1168 did not repeat.

What I expected

My prediction was the same for the two hosts. The GPU does the same 206 us of work (Chapter 7), and two foreign calls cost single-digit microseconds. I wrote 'same p50, different tail' before I ran anything. I expected that Python would have the spiky tail because of the GIL and the collector.

I expected that the worst tick of Rust would be its plus a small quantity. I did not expect that the scheduler would give the largest number on the page.

The theory

A floor for the timed region, then the pacer's arithmetic

Timed region, per frame (arithmetic)

kernel alone: 0.206 ms (Day 7)
DMA of 2.76 MB at 18 GB/s (Day 8): 0.15 ms
pageable source: CPU memcpy to staging first,
  2.76 MB at 5 to 10 GB/s (est.) = 0.3 to 0.55 ms
sync + 2 foreign calls: ~10 us (est.)
= floor 0.67 to 0.92 ms

THE PACER AT 30 Hz

period = 33,333 us
work measured: 2327 us Python, 4284 us Rust (mean)
so sleep ~29 to 31 ms per tick
spin tail 0.5 ms x 30 = 15 ms per s
= 1.5% of a core, burned on purpose
late only if sleep wakes > 0.5 ms after asked

The p50 of Rust (967 us) is 5% above the top of the floor. The p50 of Python (1777 us) is the floor plus 0.8 ms, and two ctypes calls cannot explain that difference. The bracket contains a copy from pageable memory: a CPU memcpy into a staging buffer, then the DMA. The frame allocation is outside the bracket in both hosts.

The paced loop did not agree with the tight loop. Inside a 30 Hz tick, the same two calls took 4284 us in Rust and 2327 us in Python, a mean over 600 ticks. Rust was 1.8x slower there and 1.8x faster in the tight loop. Same kernel, same calls. Most likely the state after a 30 ms sleep, cache or CPU clock; not tested. Three reruns on 2026-10-04 gave the same order: 4,102 to 4,295 us Rust, 2,394 to 2,418 Python.

TAILS, p99 MINUS p50, AND THE WORST TICK (arithmetic on measured)

Python tight
349 us
Rust tight
984 us
Python worst tick
40 us
Rust worst tick
1168 us
The pacer sleeps to 0.5 ms before the deadline, so a tick 1168 us late means the sleep woke 1.67 ms after it was asked. That fits a scheduler time slice: CFS wakeup granularity is a few ms by default (estimate).

The setup

Build and run on the board (jetson-8gb-kernels/day09/run.sh)
cd jetson-8gb-kernels/day09 && export PATH=/usr/local/cuda/bin:$HOME/.cargo/bin:$PATH && bash run.sh
# builds libfused.so (nvcc) and host_rs (cargo), runs host_py.py and host_rs, writes results.json, runs parity.py
cat results.json
Prove there is no Python in the binary
ldd host_rs/target/release/host_rs | grep -E 'python|fused|cudart'
From results.json, written by run.sh (committed as day09/results.jetson.json)
"python": { "host": "python", "startup_ms": 294.369 }
"rust": { "host": "rust", "startup_ms": 94.46 }
"rust_binary_bytes": 509704
"ldd_libpython": ""
"parity_max_abs_diff": "0.000000 (PASS)"

Environment and script

  • Script: jetson-8gb-kernels/day09/run.sh (shim.cu, host_py.py, host_rs, parity.py). Output: results.json.
  • Board: Orin Nano Super, 15W (measured.json). Clocks not logged.
  • Toolchain: CUDA 12.6, nvcc -arch=sm_87, rustc 1.98.
  • Scheduler: stock Linux, no real-time priority, no chrt.

The measurement

WhatExpectedMeasuredNote
tight loop p50 / p99 / max, Python-1777 / 2126 / 2155 usctypes, 1000 frames
tight loop p50 / p99 / max, Rustsame p50967 / 1951 / 2681 usextern C, no crates
30 Hz tick lateness p50 / p99 / max, Pythonspiky0 / 1 / 40 us0 ticks > 2 ms
30 Hz tick lateness p50 / p99 / max, Rustsmooth0 / 0 / 1168 us0 ticks > 2 ms
30 Hz work inside the tick, mean, Python / Rustsame2327 / 4284 usupload + run inside the paced tick; Rust 1.8x slower
startup to first kernelRust fasterPy 294 ms / Rust 94 ms
Rust binary size / libpythonsmall / none0.51 MB / noneldd
parity vs Day 7 reference00.000000 (PASS)same kernel
Rerun 2026-10-04, tight loop p50 / p99, Python, n=3-1,622 / 1,851 usp50 1,593 to 1,660; p99 1,816 to 1,899
Rerun 2026-10-04, tight loop p50 / p99, Rust, n=3-984 / 2,143 usp50 962 to 995; p99 2,138 to 2,168, wider than Python in each run
Rerun 2026-10-04, 30 Hz work inside the tick, Python / Rust, n=3-2,397 / 4,250 usPython 2,394 to 2,418; Rust 4,102 to 4,295; 1.7x to 1.8x
Rerun 2026-10-04, worst tick lateness, Python / Rust, n=3-11 to 12 us / 0.5, 0.5, 31 usthe 1168 us did not repeat; 0 ticks over 2 ms in all six loops
Measured on the board unless the row says estimate or predicted.
Same fused kernel, two hosts: per-frame time and 30 Hz tick lateness, 15W
Fig 9.1Same fused kernel, two hosts: per-frame time and 30 Hz tick lateness, 15W.

What sits inside the clock, in either host

  1. Fresh frame2.76 MB pageable
  2. CPU memcpyto staging
  3. DMA to GPU18 GB/s
  4. Kernel 206 us+ stream sync
The language is the first and last few microseconds of this chain. The middle is the staging memcpy and the copy engine. The frame is built before the clock starts.
Python p50
1777 us
Rust p50
967 us
Python max
2155 us
Rust max
2681 us
Tight loop, 1000 frames. p50 says Rust, max says Python. The loop I ship lives with the max.
whatmy priorthe board
tight loop p50 / p99 / max, Python-1777 / 2126 / 2155 us
tight loop p50 / p99 / max, Rustsame p50967 / 1951 / 2681 us
30 Hz lateness p50 / p99 / max, Pythonspiky0 / 1 / 40 us
30 Hz lateness p50 / p99 / max, Rustsmooth0 / 0 / 1168 us
30 Hz work inside the tick, mean, Py / Rustsame2327 / 4284 us
ticks over 2 ms, either host-0
startup to first kernel, Py / RustRust faster294 / 94 ms
Rust binary / links libpythonsmall / no0.51 MB / no
parity vs Day 7 reference00.000000 PASS
rerun 2026-10-04, tight p50 / p99, Python / Rust, n=3-1,622 / 1,851 vs 984 / 2,143 us
rerun 2026-10-04, 30 Hz work inside the tick, Py / Rust, n=3same2,397 / 4,250 us
rerun 2026-10-04, worst tick, Py / Rust, n=3-11 to 12 / 0.5, 0.5, 31 us
Measured: 15W, same fused kernel behind a 5-call C API. Startup: 294 minus 94 = 200 ms is imports. The 94 both pay is mostly the CUDA context (estimate).

Where it broke

The p50 values were not equal. 810 us is too large for two foreign calls. cudaMemcpyAsync from pageable memory is a CPU memcpy into a staging buffer before the DMA, and that copy is inside the bracket. The allocation of each 2.76 MB frame is outside it: make_frame runs before the timer in both hosts. My suspect, not measured: the numpy frame leaves the cache before the clock starts, so the staging memcpy reads from DRAM in Python and from cache in Rust.

The paced loop then reversed the order. Inside a 30 Hz tick the same calls took 4284 us in Rust and 2327 us in Python. A result that flips with the loop shape is not a language result. I did not isolate it. Three reruns on 2026-10-04 repeated the flip: 4,102 to 4,295 us Rust against 2,394 to 2,418 Python inside the tick, 962 to 995 against 1,593 to 1,660 in the tight loop.

The tail went in the opposite direction. The tight-loop tail of Rust is 2.8x wider. In one run of 600 ticks for each host, its worst tick is 29x later. 'Smooth vs spiky' was an explanation that I wanted, not a result. In the 2026-10-04 reruns Rust's p99 stayed at 2,138 to 2,168 us and its worst tick fell to 0.5, 0.5 and 31 us.

My aim was to measure a language, but my bracket contained a pageable copy. The first item inside the clock must be the item under test. A (cudaHostAlloc) in the two hosts would take the copy out of the bracket.

The advantage that stays is operational: 94 ms to first kernel, 0.51 MB, no libpython, no venv. That is a sufficient reason to keep the Rust host.

My notes from Day 9

Written by hand on 2026-09-09, before this chapter was typed. These are my original notes from that day. Where the chapter corrects them, the chapter is current.

Handwritten notes, Day 9, page 1
Day 9, page 12026-09-09
Handwritten notes, Day 9, page 2
Day 9, page 22026-09-09

Summary

  • Rust does not make Ampere faster. With a pageable copy inside the timed loop, the p50 of Rust was 967 us against 1777 us for Python. Two foreign calls cannot explain the 810 us gap.
  • The paced loop reversed the tight loop. Inside a 30 Hz tick the same upload and run took 4284 us in Rust and 2327 us in Python, 1.8x slower in Rust. I have not isolated the cause. Three reruns on 2026-10-04 repeated it: 4,102 to 4,295 us against 2,394 to 2,418, 1.7x to 1.8x.
  • For a walker, the important number is the max lateness, not the mean. With the stock Linux scheduler and no real-time priority, the worst tick of Rust was 1168 us against 40 us for Python. In three reruns on 2026-10-04 Rust's worst tick was 0.5, 0.5 and 31 us: the 1168 us did not repeat.
  • There is no venv now. There is one binary and one .so. That is an operational advantage, also on the days when the timing shows a tie.

Sources

These sources are not mine. Measured numbers came off my own board; specs, estimates and paper figures are labeled as such.

  • cudarc docs (driver API from Rust)
  • CubeCL / kornia-rs Jetson GSoC 2026 (GPU preprocess for VLMs on Nano)
  • CUDA-Oxide, an experimental Rust-to-PTX path (not used here)
  • day09/rerun-2026-10-04/summary.json: day09/run.sh three times on 2026-10-04 (results_run1..3.json, tegrastats beside each)