The 8 GB Lab Book · Chapter 7
A fused Ampere preprocess kernel
- Board
- Jetson Orin Nano Super
- Mode
- 15W
- Setup
- CUDA 12.6 · sm_87
- Summary
- Fusing three kernels into one cut the preprocess from 479.9 us to 205.5 us per frame. That is 2.34x, close to the 2.43x of the byte ledger, not 3x. Both versions move bytes at 31 to 33 GB/s, under half the 68 GB/s roof. Three reruns on 2026-10-04 gave 2.33x to 2.34x with the same 32.6 to 32.7 against 31.4 GB/s.
What I expected
There are three kernels and three trips to DRAM. I wrote 3x on the whiteboard before I counted one byte. My unit was the trip, and a trip has no size.
I also expected that registers would decide occupancy, because that is the number that everyone quotes. I expected 32 registers for each thread. I set no limit in the code.
The theory
A byte ledger per pass, then a roofline floor for each path
Path a, three passes (arithmetic)
k1 resize: 2.76 in + 0.92 out = 3.69 MB k2 normalize: 0.92 in + 3.69 out = 4.61 MB k3 layout: 3.69 in + 3.69 out = 7.37 MB = = 15.67 MB through DRAM per frame
Path b, one pass
read the u8 frame once: 2.76 MB write the f32 tensor once: 3.69 MB the middle values live in registers = = 6.45 MB through DRAM per frame
The predicted ratio is 15.67 / 6.45 = 2.43x, not 3x. The passes are not equal, because u8 is a quarter of f32. The first pass is cheap and the layout pass is the expensive pass. As a check, ledger / measured time = 32.7 and 31.4 GB/s. The board reported 32.6 and 31.4.
ROOFLINE FLOOR AT 68 GB/s (arithmetic) NEXT TO THE BOARD (measured, 1000 frames)
- A floor
- 230 us
- A on the board
- 479.9 us
- B floor
- 95 us
- B on the board
- 205.5 us
Why 6 blocks per SM, and why registers were never the limit
The SM holds 6 blocks of 256 = 1536 threads and calls that 1.00. 1536 is the thread cap. For registers, 64K per SM (spec) / (28 x 256 per block) = 9.1 blocks. Threads bind first. Registers could increase to 65536 / 1536 = 42 for each thread before they cost a block. My 32 was a number that people repeat.
For the work, there is one thread for each 4 outputs (one float4 write). 921,600 / 4 = 230,400 threads = 900 blocks. 8 SMs x 6 = 48 resident blocks, so 900 / 48 = 19 waves. Each thread does 4 x 4 = 16 scalar u8 reads for one 16 B write. The read side is the irregular side.
The setup
cd jetson-8gb-kernels/day07 && export PATH=/usr/local/cuda/bin:$PATH && bash run.sh
# builds preprocess.cu (nvcc -O3 -arch=sm_87), prints path A / path B mean p50 p99, GB/s, occupancy per kernel, writes results.json, then runs parity.py PASS/FAILcat jetson-8gb-kernels/day07/results.json
# mean_us A vs B, speedup. Registers and occupancy are in the stdout block above RESULT_JSON.nvcc -O3 -arch=sm_87 -Xptxas -v -o /dev/null preprocess.cu 2>&1 | grep -E 'registers|spill'"src_w": 1280
"src_h": 720
"dst_w": 640
"dst_h": 480
"warmup_iters": 50
"timed_iters": 1000
"path_a": { "mean_us": 479.893, "p50_us": 479.84, "p99_us": 483.744, "gbps": 32.647 }
"path_b": { "mean_us": 205.514, "p50_us": 205.472, "p99_us": 207.36, "gbps": 31.391 }
"speedup": 2.335Environment and script
- Script: jetson-8gb-kernels/day07/run.sh (preprocess.cu, parity.py). Output: results.json.
- Board: Orin Nano Super, L4T R36.4.2, 15W (measured.json). Clocks not logged.
- Toolchain: CUDA 12.6, nvcc -O3 -arch=sm_87.
- Timing: cudaEvent, 50 warm-up and 1000 timed iterations per path.
The measurement
| What | Expected | Measured | Note |
|---|---|---|---|
| Path A, 3 kernels, mean us/frame | 3 launches + 3 round trips | 479.9 us | p99 483.7 us |
| Path B, fused, mean us/frame | 1 launch, 1 round trip | 205.5 us | p99 207.4 us |
| Speedup A/B | ~3x if bandwidth-only | 2.34x | resize taps are scalar; not a pure 3x |
| Achieved GB/s, A / B | < 68 roof | 32.6 / 31.4 | bytes read+written / time |
| Registers/thread, fused | < 32 for full occupancy | 28 | cudaFuncGetAttributes |
| Occupancy, fused (256-thr blocks) | - | 6 blocks/SM, 1.00 | cudaOccupancyMaxActiveBlocksPerMultiprocessor |
| Spills | 0 | 0 | -Xptxas -v shows no spill stores |
| Parity vs OpenCV+numpy, 10 frames | < 1e-2 | 0.000000 max abs | half-pixel centers, clamped edges |
| Rerun 2026-10-04, A / B mean us/frame, n=3 | - | 479.1 / 205.6 us | A 478.7 to 480.5, B 205.3 to 205.6; p99 482.7 to 487.7 / 207.8 to 208.3; parity 0.000000 all three |
| Rerun 2026-10-04, speedup; GB/s A / B, n=3 | 2.43x | 2.33x; 32.7 / 31.4 | runs 2.3402 / 2.3290 / 2.3306; B moved bytes slower than A in every run |

Every box is a full pass over DRAM
- u8 frame2.76 MB
- K1 resizeu8 out 0.92 MB
- K2 normalizef32 out 3.69 MB
- K3 layoutf32 out 3.69 MB
- u8 frame2.76 MB
- Fused kernelregisters only
- f32 tensor3.69 MB
| what | my prior | the board |
|---|---|---|
| A mean / p99 | 3 passes | 479.9 / 483.7 us |
| B mean / p99 | 1 pass | 205.5 / 207.4 us |
| speedup A / B | 3x, then 2.43x | 2.34x |
| GB/s moved, A / B | under 68 | 32.6 / 31.4 |
| registers per thread, fused | under 32 | 28 |
| blocks per SM, occupancy | - | 6, 1.00 |
| spills | 0 | 0 |
| parity vs OpenCV, 10 frames | under 1e-2 | 0.000000 max abs |
| rerun 2026-10-04, A / B mean, n=3 | - | 479.1 / 205.6 us |
| rerun 2026-10-04, speedup; GB/s A / B, n=3 | 2.43x | 2.33x; 32.7 / 31.4 |
Where it broke
3x became 2.43x on paper and 2.34x on the board. The first decrease comes from a count of bytes and not of trips. The second decrease, 4%, is not the launches. Fewer launches would make B faster than the ledger, not slower. The board shows the opposite: B moved its bytes at 31.4 GB/s against 32.6 for A. The fused pass is a little less efficient for each byte. Most likely the 16 scalar u8 reads for each thread; not tested. Three reruns on 2026-10-04 held the order: 2.3402, 2.3290 and 2.3306, with B at 31.38 to 31.42 GB/s under A at 32.61 to 32.73 in every run.
The surprise is the thing that did not change. A and B each move bytes at 31 to 33 GB/s, which is less than half of the roof. The kernel fusionCombining several GPU operations into one kernel so intermediate results stay on chip instead of making a round trip through DRAM. removed passes, but it did not make the pass faster.
I counted trips and called the count a prediction. A trip has no size, but a byte has a size. The ledger has four lines. It could give me 2.43x before I compiled anything.
I monitored the incorrect limit. At 256 threads for each block, the thread cap of the SM binds much earlier than the registers.
2.34x is an improvement in bytes, not in the kernel. The two paths run at less than half of the roof: 31 GB/s against 68.
My notes from Day 7
Written by hand on 2026-09-07, before this chapter was typed. These are my original notes from that day. Where the chapter corrects them, the chapter is current.


Summary
- On a board with shared memory, the preprocess uses the same bus that the LLM uses to decodeThe phase after prefill where a language model generates one token at a time and re-reads its weights for each one; it is usually limited by memory bandwidth.. Path A moves 15.67 MB for each frame. Path B moves 6.45 MB.
- The driver gives the occupancy number at no cost. cudaOccupancyMaxActiveBlocksPerMultiprocessor gave 6 blocks for each SM, occupancy 1.00.
- Bilinear is a contract. It is not a preference. Later, TensorRTNVIDIA's inference compiler and runtime, which turns a trained network into an execution plan tuned for one specific GPU. input parity depends on a resize that is the same function that OpenCV computes.
Sources
These sources are not mine. Measured numbers came off my own board; specs, estimates and paper figures are labeled as such.
- CUDA C++ Programming Guide (occupancy, warps, shared memory)
- Ampere tuning (vectorized global loads)
- Nsight Compute occupancy calculator. sm_87 is the Orin ISA. Binaries built only for sm_90 do not run on it
- day07/rerun-2026-10-04/summary.json: day07/run.sh three times on 2026-10-04 (results_run1..3.json, tegrastats beside each)