Paper · 2021-03-25 · cs.CV
KiloNeRF: Speeding up Neural Radiance Fields with Thousands of Tiny MLPs
KiloNeRF replaces NeRF's single large MLP with a uniform 3D grid of up to 16x16x16 tiny MLPs (4 hidden layers, 32 units), each covering one cell. It is trained by distilling a pretrained NeRF, then fine-tuned on the images, and rendered with empty space skipping and early ray termination. Authors report quality on par with NeRF at 1258-2167x faster rendering.
Neural radiance field (NeRF) and variantsKnowledge distillationNovel view synthesisEmpty space skipping3drenderinginference-speeddistillation
arXivPDFCodeMarkdown13 min read
AI-compiled by claude-opus-5-5 on 2026-10-18, not yet reviewed by a human. Every claim cites its source; check it when a detail matters.
In plain words
A neural radiance field (NeRF) stores a 3D scene inside a neural network: give it a point in space and a viewing direction, and it returns a colour and how solid that point is. To draw one image you ask the network about hundreds of points along the ray of every pixel. With a big network, that takes close to a minute per image, far too slow for anything interactive.
KiloNeRF cuts the scene into a 3D grid of up to a few thousand boxes and gives each box its own very small network. A small network only has to remember a small piece of the scene, so it can be tiny and still look right. On top of that, the renderer skips points it already knows are empty air and stops walking along a ray once the ray has hit something opaque.
Training thousands of tiny networks straight from photos leaves floating smudges in empty space. So the authors first train a normal NeRF, teach each tiny network to copy that "teacher" inside its box, and only then fine-tune everything on the photos. A small penalty on the part of each network that handles view-dependent colour removes the remaining smudges.
The result, as reported by the authors, is images of about the same quality as the original NeRF, rendered roughly two thousand times faster on a consumer GPU, which turns minute-long renders into tens of milliseconds at 800x800.
TL;DR
KiloNeRF partitions a bounded scene's AABB into a uniform grid (largest side 16 cells) and assigns an independent tiny MLP (4 hidden layers of 32 units) to each cell §3.2 §4.1. Training is three-stage: a NeRF teacher without hierarchical sampling, per-cell distillation on α and RGB, then photometric fine-tuning with L2 weight regularization on the view-dependent layers §3.3 §4.1. Rendering uses an occupancy grid extracted from the teacher for empty space skipping plus early ray termination, and a custom kernel for evaluating thousands of networks on variable-sized batches §3.4 §3.5. Author-reported: comparable PSNR/SSIM/LPIPS to NeRF and NSVF on four datasets, with render times of 26-91 ms versus 37-183 s for NeRF on a GTX 1080 Ti tab. 1. Relevant if you need fast NeRF inference for bounded scenes and can afford a long training pipeline and custom CUDA.
Problem
NeRF evaluates a deep MLP (10 hidden layers, mostly 256 wide) for every one of the W \times H \times K sample points of an image, which makes rendering very slow even on modern GPUs §1 §3.1. Interactive uses such as VR require real-time rendering on the consumer's own device, whereas training can be parallelized on a cluster, so the paper targets rendering speed only §1. Simply shrinking the MLP loses quality because a small network cannot fit a complex scene §1. Assumptions: the scene is bounded and its AABB is known §3.2 §5.
Method
Background (NeRF) §3.1. A network f_\theta maps a positionally encoded 3D point \mathbf{x} and view direction \mathbf{d} to colour \mathbf{c} and density \sigma; only \mathbf{c} depends on \mathbf{d}. For K samples along a ray:
(\mathbf{c}_i, \sigma_i) = f_\theta(\mathbf{x}_i, \mathbf{d}), \quad i = 1, \ldots, K eq. 1
\hat{\mathbf{c}} = \sum_{i=1}^{K} T_i \alpha_i \mathbf{c}_i eq. 2
T_i = \prod_{j=1}^{i-1} (1 - \alpha_j) eq. 3
\alpha_i = 1 - \exp(\sigma_i \delta_i) eq. 4
with \delta_j = \lVert \mathbf{x}_{j+1} - \mathbf{x}_j \rVert §3.1. Eq. 4 is printed without the minus sign in the exponent; the code uses 1 - \exp(-\mathrm{ReLU}(\sigma)\,\delta) run_nerf.py (see Pitfalls). Training samples one image and B of its pixels per step and minimizes
\mathcal{L} = \frac{1}{B} \sum_{b}^{B} \lVert \mathbf{c}_b - \hat{\mathbf{c}}_b \rVert_2^2 eq. 5
Grid of tiny MLPs §3.2. Given AABB bounds \mathbf{b}_{min}, \mathbf{b}_{max} and grid resolution \mathbf{r} = (r_x, r_y, r_z), each cell index \mathbf{i} owns parameters \theta(\mathbf{i}). A point is routed by element-wise spatial binning:
g(\mathbf{x}) = \left\lfloor (\mathbf{x} - \mathbf{b}_{min}) / ((\mathbf{b}_{max} - \mathbf{b}_{min}) / \mathbf{r}) \right\rfloor eq. 6
(\mathbf{c}, \sigma) = f_{\theta(g(\mathbf{x}))}(\mathbf{x}, \mathbf{d}) eq. 7
The grid's largest dimension is 16 and the others are chosen so cells are cubic, giving at most 4096 networks §4.1. In the code, each network receives the point in local coordinates of its own cell, rescaled to [-1, 1] per axis, before positional encoding local_distill.py multi_modules.py; the paper does not mention this.
Tiny network architecture §3.2 fig. 2. A downscaled NeRF MLP: 4 hidden layers of 32
units, no skip connection, view direction fed into the last hidden layer, density independent
of direction. All affine layers use ReLU except the RGB output (sigmoid) and the feature vector
passed into the penultimate hidden layer (no activation) §3.2. A forward pass needs 1/87 of
NeRF's FLOPs fig. 2; one such MLP has about 6k parameters §4.3. Concretely, in the code
(num_hidden_layers: 2, late_feed_direction: true) the layer stack is
multi_modules.py Synthetic_NeRF_Lego.yaml:
# One KiloNeRF cell network, as built in code [§3.2] [fig. 2]
# [code: multi_modules.py#L395-L417] [code: multi_modules.py#L459-L503]
# Input: pos_enc (N, 63) from 10 frequencies; dir_enc (N, 27) from 4
# Output: rgb (N, 3), raw density (N, 1)
def tiny_mlp(pos_enc, dir_enc):
# first two hidden layers on the encoded position, ReLU [§3.2]
hidden = relu(linear_in(pos_enc)) # (N, 32)
hidden = relu(linear_hidden(hidden)) # (N, 32)
# density head reads the position features only [§3.2]
raw_density = linear_alpha(hidden) # (N, 1)
# feature layer has no activation [§3.2]
feature = linear_feature(hidden) # (N, 32)
# view direction joins here; this and rgb are "view-dependent" [§3.2]
view_hidden = relu(linear_direction(concat(feature, dir_enc)))
# sigmoid output colour [§3.2]
rgb = sigmoid(linear_rgb(view_hidden)) # (N, 3)
return rgb, raw_densityThree-stage training §3.3 §4.1.
- Train a teacher NeRF without hierarchical sampling for 600k iterations; NeRF's coarse model is too inaccurate and its fine model too biased towards surfaces to distil from §4.1.
- Distil: for every cell network, sample random 3D points inside its cell and random view directions on the unit sphere, query student and teacher, convert both densities to α with eq. 4 (to de-emphasize differences between large densities), and minimize an L2 loss on α and RGB §3.3. No rendering and no training images are used. 150k iterations §4.1.
- Fine-tune on the training images with the photometric loss (eq. 5) for 1000k iterations, using the teacher-derived occupancy grid to skip empty space §3.3 §3.4 §4.1. Add L2 regularization to the weights and biases of the last two layers (direction layer and RGB layer), weight 1e-6 §3.3 §4.1.
# Distillation stage [§3.3]
# [code: local_distill.py#L300-L347] [code: local_distill.py#L131-L166]
def distill(teacher, students, cells, iterations):
# pre-sample a fixed pool of points and directions per cell
# (code: 1.02M per network, 1M train / 20k held out)
for cell in cells:
points[cell] = uniform_in_box(cell.min, cell.max, pool_size)
dirs[cell] = normalize(gaussian(pool_size, 3))
# teacher sees global coords; colour via sigmoid, density via ReLU
rgb_t, sigma_t = teacher(points[cell], dirs[cell])
# fixed "typical" spacing; code: alpha_distance = 0.0211
alpha_t = 1 - exp(-relu(sigma_t) * alpha_distance)
targets[cell] = concat(rgb_t, alpha_t)
for step in range(iterations):
# code: 128 examples per network per step, all networks at once
batch = sample_indices(pool_size, per_network_batch)
local = to_local_coords(points[:, batch], cells) # in [-1, 1]
rgb_s, sigma_s = students(encode(local), encode(dirs[:, batch]))
# code uses leaky_relu on the student density here
alpha_s = 1 - exp(-leaky_relu(sigma_s) * alpha_distance)
# L2 on (rgb, alpha); mean per network, summed over networks
loss = sum_over_networks(
mean(square(concat(rgb_s, alpha_s) - targets[:, batch]))
)
adam_step(loss)# Fine-tuning stage [§3.1] [§3.3] [§3.4] [eq. 5]
# [code: run_nerf.py#L981-L1019]
def finetune_step(image, students, occupancy, step):
# 8192 pixels of one training image per step [§4.1]
rays, target_rgb = sample_pixels(image, 8192)
# K = 384 stratified equidistant samples, no hierarchical pass [§3.4]
points, deltas = stratified_samples(rays, 384)
# empty space skipping: evaluate only points in occupied cells [§3.4]
active = occupancy[voxel_index(points)]
rgb, sigma = query_networks(students, points[active], rays.dirs)
# alpha compositing of eq. 2-4; no early termination in training [§3.4]
pred_rgb = composite(rgb, sigma, deltas, active)
loss = mean(square(pred_rgb - target_rgb))
# L2 on direction layer + rgb layer, weight 1e-6 [§3.3] [§4.1]
# code: sum of (non-squared) L2 norms of those tensors
loss = loss + 1e-6 * sum(norm(p) for p in view_dependent_params)
adam_step(loss)
# NeRF-style exponential decay; code: 0.1 ** (step / 500k)
set_lr(initial_lr * 0.1 ** (step / 500_000))Sampling at render time §3.4.
- At most K equidistant points per ray, with stratified sampling; K effectively sets the spacing \delta. No hierarchical sampling §3.4.
- Empty space skipping (ESS): a second, finer uniform occupancy grid (network grid resolution × 16, so at most 256³) stores one bit per cell. Each occupancy cell is filled by querying teacher densities on a 3×3×3 sub-grid inside it and marking it occupied if any density exceeds \tau = 10 §3.4 §4.1 build_occupancy_tree.py. Used for fine-tuning and inference §3.4.
- Early ray termination (ERT): stop sampling a ray once T_i < \epsilon = 0.01; inference only. Implemented by evaluating samples in passes ordered by distance from the camera and dropping terminated rays between passes §3.4 §4.1. In the benchmark render config each pass advances at most 8 samples per ray benchmark.yaml fast_kilonerf_renderer.py.
# Inference with ESS + ERT [§3.4] [§3.5]
# [code: fast_kilonerf_renderer.py#L76-L174]
def render(rays, networks, occupancy, max_samples=384, eps=0.01):
transmittance = ones(num_rays)
color = zeros(num_rays, 3)
active_rays = all_rays
depth_index = 0
while active_rays and depth_index < max_samples:
# next group of samples, nearest first; code: 8 per pass
points = next_samples(active_rays, depth_index, per_pass)
# ESS: drop points in empty occupancy cells [§3.4]
points = points[occupancy[voxel_index(points)]]
# route each point to its cell network [eq. 6]
net_ids = floor((points - b_min) / ((b_max - b_min) / grid_res))
# sort so each network's inputs are contiguous [§3.5]
points, batch_sizes = group_by_network(points, net_ids)
# fused kernel: all tiny MLPs, variable batch sizes [§3.5]
rgb, sigma = fused_multi_mlp(networks, points, batch_sizes)
color, transmittance = composite_inplace(
color, transmittance, rgb, sigma
)
# ERT: retire rays whose transmittance fell below eps [§3.4]
active_rays = active_rays[transmittance[active_rays] >= eps]
depth_index += per_pass
return colorImplementation §3.5. PyTorch, MAGMA and Thrust plus custom CUDA kernels for sampling, ESS, ERT, positional encoding, network evaluation and α-blending. Each linear layer becomes N independent products of a B_i \times I matrix with an I \times O matrix where B_i varies per network, so ordinary batched matmul does not apply; training uses MAGMA's variable-size batched GEMM, inference a self-written kernel that fuses the whole network. Inputs must first be sorted by network, which is cheap once ESS and ERT have removed most samples §3.5.
Hyperparameters
| Name | Value | Where | Notes |
|---|---|---|---|
| network grid | largest side 16, cubic cells; ≤ 16×16×16 = 4096 | §4.1 | Lego: 10×16×10 in §4.3; code config 9×16×10 Synthetic_NeRF_Lego.yaml |
| occupancy grid | network grid × 16; ≤ 256³ | §4.1 | Lego code: 144×256×160 Synthetic_NeRF_Lego.yaml |
| occupancy sub-samples per cell | 3×3×3 | §3.4 | Synthetic_NeRF_Lego.yaml |
| occupancy threshold τ | 10 (density) | §4.1 | same for all datasets |
| tiny MLP | 4 hidden layers, 32 units, no skip | §3.2 | code: num_hidden_layers: 2 + feature + direction layer |
| positional encoding | same frequencies as NeRF | §4.1 | code: 10 for position, 4 for direction Synthetic_NeRF_Lego.yaml |
| optimizer | Adam, lr 5e-4 | §4.1 | fine-tune config uses 1e-3 Synthetic_NeRF_Lego.yaml; Adam betas (0.9, 0.999) run_nerf.py |
| batch size (fine-tune) | 8192 pixels | §4.1 | one image per step (no_batching: true) Synthetic_NeRF_Lego.yaml |
| lr decay | same as NeRF | §4.1 | code: lr·0.1^(step/500k) run_nerf.py |
| L2 weight (view-dependent layers) | 1e-6 | §4.1 | applied to direction + RGB layers multi_modules.py |
| ERT threshold ε | 0.01 | §4.1 | taken from NSVF |
| max samples per ray K | 384 | §4.1 | stratified, equidistant |
| teacher | NeRF without hierarchical sampling, 600k iterations | §4.1 | code: 8 layers × 256, skip at layer 4, 1024 rays/batch, 384 samples, lr 5e-4 Synthetic_NeRF_Lego.yaml |
| distillation iterations | 150k | §4.1 | |
| distillation lr | not stated | code default 1e-3 Adam local_distill.py | |
| distillation batch | not stated | code: 128 examples per network per step, up to 512 networks at once Synthetic_NeRF_Lego.yaml | |
| distillation α spacing δ | not stated | code: 0.0211 Synthetic_NeRF_Lego.yaml | |
| fine-tuning iterations | 1000k | §4.1 | |
| hardware | GTX 1080 Ti, i7-3770k, 32 GB RAM | §4.1 |
Results
Author-reported. Setup: NSVF's four bounded-scene datasets with NSVF's bounding boxes, 25 scenes total; baselines are original NeRF (with hierarchical sampling) trained for a long schedule, and NSVF; render time averaged over test images on a GTX 1080 Ti §4.1 tab. 1.
| Metric | Method | BlendedMVS (768×576) | Synthetic-NeRF (800×800) | Synthetic-NSVF (800×800) | Tanks & Temples (1920×1080) | Source |
|---|---|---|---|---|---|---|
| PSNR ↑ | NeRF | 27.29 | 31.01 | 31.55 | 28.32 | tab. 1 |
| NSVF | 26.90 | 31.74 | 35.13 | 28.40 | tab. 1 | |
| KiloNeRF | 27.39 | 31.00 | 33.37 | 28.41 | tab. 1 | |
| SSIM ↑ | NeRF | 0.91 | 0.95 | 0.95 | 0.90 | tab. 1 |
| NSVF | 0.90 | 0.95 | 0.98 | 0.90 | tab. 1 | |
| KiloNeRF | 0.92 | 0.95 | 0.97 | 0.91 | tab. 1 | |
| LPIPS ↓ | NeRF | 0.07 | 0.08 | 0.04 | 0.11 | tab. 1 |
| NSVF | 0.11 | 0.05 | 0.01 | 0.15 | tab. 1 | |
| KiloNeRF | 0.06 | 0.03 | 0.02 | 0.09 | tab. 1 | |
| Render time (ms) ↓ | NeRF | 37266 | 56185 | 56185 | 182671 | tab. 1 |
| NSVF | 4398 | 4344 | 10497 | 15697 | tab. 1 | |
| KiloNeRF | 30 | 26 | 26 | 91 | tab. 1 | |
| Speedup over NeRF ↑ | NSVF | 8 | 13 | 5 | 12 | tab. 1 |
| KiloNeRF | 1258 | 2165 | 2167 | 2002 | tab. 1 |
Speedup breakdown on Lego tab. 2:
| Method | Render time | Speedup | Source |
|---|---|---|---|
| NeRF | 56185 ms | – | tab. 2 |
| NeRF + ESS + ERT | 788 ms | 71 | tab. 2 |
| KiloNeRF | 22 ms | 2548 | tab. 2 |
So ESS/ERT alone give 71×; the smaller networks contribute the remaining factor of roughly 36 tab. 2 §4.3. Ablations on Lego, judged by LPIPS on close-ups fig. 5 §4.3: a single tiny MLP for the whole scene degrades quality dramatically; halving the grid (5×8×5) hurts more than halving hidden units; skipping fine-tuning hurts substantially; training without distillation gives similar detail but free-space artifacts fig. 3; removing L2 regularization also causes free-space artifacts fig. 6. Training time: teacher NeRF ~2 days on a 1080 Ti (for the NeRF baseline), distillation up to 8 h, fine-tuning 17 h §4.1. All evaluated scenes need less than 100 MB of storage §5.
Paper vs code
Checked against creiser/kilonerf at e79af85. The repo has no LICENSE file, so nothing is
quoted from it.
- Fine-tuning learning rate: paper 5e-4 §4.1; the paper's fine-tune configs use 1e-3 Synthetic_NeRF_Lego.yaml (the global default is 5e-4 default.yaml, which the teacher config matches).
- Lego network grid: paper 10×16×10 §4.3; config 9×16×10 with occupancy 144×256×160 Synthetic_NeRF_Lego.yaml Synthetic_NeRF_Lego.yaml. Both have 16 on the largest side.
- L2 regularization: the paper says L2 on weights and biases §3.3; code adds λ times the sum of the non-squared 2-norms of each tensor of the direction and RGB layers run_nerf.py.
- Distillation density activation: teacher targets use ReLU before α conversion local_distill.py, the student uses leaky ReLU during distillation training local_distill.py. Not mentioned in the paper.
- Distillation α conversion uses a fixed spacing
alpha_distance: 0.0211, not the actual per-ray δ Synthetic_NeRF_Lego.yaml; the paper only refers to eq. 4. - Local coordinates: every cell network sees its input rescaled to [-1, 1] within its cell local_distill.py multi_modules.py; the paper does not say.
- The distillation script is a more general adaptive k-d tree fitter; with
fixed_resolutionandmax_error: 100000no cell is ever split, which reduces it to the paper's uniform grid local_distill.py local_distill.py. - Teacher training uses 1024 rays per batch and center-cropping for the first 10k iterations Synthetic_NeRF_Lego.yaml; the paper only says "NeRF without hierarchical sampling" §4.1.
Pitfalls
- Sign error in eq. 4. As printed, \alpha_i = 1 - \exp(\sigma_i\delta_i) is negative for positive density. Use 1 - \exp(-\mathrm{ReLU}(\sigma_i)\,\delta_i), as the code does eq. 4 run_nerf.py.
- Local vs global coordinates. Students take cell-local coordinates in [-1, 1] while the teacher takes global coordinates local_distill.py local_distill.py. Feeding global coordinates to the tiny MLPs wastes positional encoding frequencies on the whole scene extent.
- "4 hidden layers" counting. The four are two position layers, the linear feature layer
and the direction layer; the density head branches off before the feature layer §3.2
multi_modules.py. In the code's config this is
num_hidden_layers: 2. - α for distillation needs a δ. The paper converts densities to α but does not say which δ; the code uses a constant 0.0211 per scene config. The choice decides how much large densities are compressed §3.3 Synthetic_NeRF_Lego.yaml.
- Distillation alone is not enough. Fine-tuning is required for quality; distillation only initializes the weights §3.3 §4.3. Skipping distillation gives free-space artifacts fig. 3, as does skipping the L2 term fig. 6. Reducing the width of the view-dependent layer, as NeRF does, was tried and lost quality §3.3.
- Teacher choice matters. Use a NeRF without hierarchical sampling as the teacher; the coarse model is too inaccurate and the fine model is surface-biased §4.1.
- Occupancy grid comes from the teacher and is frozen. Geometry the teacher misses at density ≤ 10 is skipped during fine-tuning and can never be recovered §3.4 §4.1.
- ERT is inference-only; training renders all non-empty samples §3.4.
- Hidden costs. The full pipeline is ~2 days of teacher training plus up to 8 h distillation plus 17 h fine-tuning on one 1080 Ti §4.1. The code pre-samples about a million points per network for distillation Synthetic_NeRF_Lego.yaml, which takes a lot of host memory when hundreds of networks are processed together.
- Speed depends on custom kernels. Thousands of networks with variable batch sizes cannot use standard batched matmul; without a grouped GEMM (MAGMA vbatched) or a fused kernel and per-network sorting, much of the speedup is lost §3.5.
- Bounded scenes only. A known AABB is required; unbounded scenes need many more networks and memory §3.2 §5.
- Baseline caveat. NeRF render times are for the original NeRF with hierarchical sampling, and the authors' NeRF baseline scores are higher than those NSVF reported, which they attribute to longer training §4.1.
Lineage
- Builds on: NeRF (arXiv:2003.08934): same field, volume rendering and photometric loss, with one large MLP replaced by a grid of tiny ones §3.1 §3.2.
- Compared against: NeRF and NSVF (arXiv:2007.11571); NSVF also uses ESS and ERT, so the speedup over it is mainly due to the smaller networks §4.1.
- Related: complementary methods that reduce samples per ray, AutoInt (arXiv:2012.01714) and DONeRF (arXiv:2103.03231) §5.
Source and license
Adapted from “KiloNeRF: Speeding up Neural Radiance Fields with Thousands of Tiny MLPs” by Christian Reiser, Songyou Peng, Yiyi Liao, Andreas Geiger (arXiv:2103.13744), used under CC BY-SA 4.0. Changes: condensed and restructured for implementation, with tables, equations and algorithms taken from the paper and its code, each cited to its source. Not affiliated with or endorsed by the authors. This page is licensed under CC BY-SA 4.0. Code citations refer to https://github.com/creiser/kilonerf at commit e79af85198f5 (none (no LICENSE file in repository)). Any quoted code is shown under that license.