Summary
Grid.from_points / GridBatch.from_points can produce different voxel sets on CPU and CUDA for the same float16 input. The two paths agree on the voxel count but disagree on which voxels are active.
float32 and float64 inputs are unaffected — they match exactly across every configuration tested.
Reproducer
import torch
from fvdb import Grid
torch.manual_seed(0)
p = (torch.randn(200_000, 3) * 3.0).to(torch.float16)
a = {tuple(r) for r in Grid.from_points(p, voxel_size=0.013, origin=0.37).ijk.tolist()}
b = {tuple(r) for r in Grid.from_points(p.cuda(), voxel_size=0.013, origin=0.37).ijk.cpu().tolist()}
print(len(a), len(b), len(a - b), a == b)
# 199977 199977 84 False
Both paths produce 199,977 voxels; 84 of them differ in each direction (199,893 shared).
Diagnosis
This is a rounding-boundary disagreement, not a logic error:
- Every disagreement is off-by-one on a single axis. All 84 CPU-only coordinates have a GPU-only coordinate at Chebyshev distance 1 differing in exactly one component (x: 23, y: 29, z: 32).
- The affected points sit essentially exactly on a
.5 rounding boundary. Computing (p - origin) / voxel_size in float64 and measuring the distance to the nearest .5, 564 points fall within 1e-6 of a boundary on some axis. Only voxels whose entire point population shifts actually change, hence 84 rather than 564.
- Neither path matches an exact float64 reference. Both compute in float32 (
at::opmath_type<c10::Half> is float), so both deviate from the exact result — they just deviate differently.
float16 makes this far more likely than the other dtypes: the input is quantized onto a coarse lattice, so with an unlucky voxel_size / origin combination a large number of points land on exactly the same fractional offset, and that offset can be exactly 0.5.
The proximate cause is that host and device evaluate the same VoxelCoordTransform::apply(...).round() expression with different intermediate rounding — most plausibly FMA contraction on the device versus separate multiply/add on the host, which shifts values sitting exactly on the tie by 1 ULP.
Not a regression
Verified against two builds of the same tree, differing only in BuildGridFromPoints.cu:
main (pre-#719): cpu=199977 gpu=199977 sym_diff=84 each way, identical=False
PR #719 : cpu=199977 gpu=199977 sym_diff=84 each way, identical=False
Identical, so this is long-standing and not introduced by #719. It was noticed while building a CPU-vs-CUDA oracle for that PR.
Why it matters
CPU and CUDA are expected to be interchangeable for grid construction. Anything that builds a grid on one device and compares against, or injects into, a grid built on the other can silently mismatch — including test oracles, which is exactly how this surfaced. It is quiet: no error is raised and the voxel counts agree, so a caller has no signal that anything is wrong.
Possible directions
Not obvious which is right, so listing rather than recommending:
- Make the transform reproducible across host and device. Suppress FMA contraction in
VoxelCoordTransform::apply (or the callers that feed .round()) so both sides evaluate identically. Cheapest fix if contraction is confirmed as the cause, but needs verification rather than assumption.
- Do the transform in double for half inputs.
at::opmath_type<c10::Half> is float; promoting to double for the coordinate computation would put the boundary cases far away from a tie. Costs throughput on a path that is otherwise memory-bound.
- Define and document a tie-breaking rule and implement it explicitly on both paths, instead of relying on whatever
.round() does with a value that is exactly .5 after two different roundings.
- Accept and document it, treating exact CPU/CUDA parity as unsupported for
float16 points. Cheapest, but leaves the trap in place.
Option 1 is worth measuring first — if contraction is the whole story it is a one-line change with no throughput cost.
Summary
Grid.from_points/GridBatch.from_pointscan produce different voxel sets on CPU and CUDA for the samefloat16input. The two paths agree on the voxel count but disagree on which voxels are active.float32andfloat64inputs are unaffected — they match exactly across every configuration tested.Reproducer
Both paths produce 199,977 voxels; 84 of them differ in each direction (199,893 shared).
Diagnosis
This is a rounding-boundary disagreement, not a logic error:
.5rounding boundary. Computing(p - origin) / voxel_sizein float64 and measuring the distance to the nearest.5, 564 points fall within 1e-6 of a boundary on some axis. Only voxels whose entire point population shifts actually change, hence 84 rather than 564.at::opmath_type<c10::Half>isfloat), so both deviate from the exact result — they just deviate differently.float16makes this far more likely than the other dtypes: the input is quantized onto a coarse lattice, so with an unluckyvoxel_size/origincombination a large number of points land on exactly the same fractional offset, and that offset can be exactly0.5.The proximate cause is that host and device evaluate the same
VoxelCoordTransform::apply(...).round()expression with different intermediate rounding — most plausibly FMA contraction on the device versus separate multiply/add on the host, which shifts values sitting exactly on the tie by 1 ULP.Not a regression
Verified against two builds of the same tree, differing only in
BuildGridFromPoints.cu:Identical, so this is long-standing and not introduced by #719. It was noticed while building a CPU-vs-CUDA oracle for that PR.
Why it matters
CPU and CUDA are expected to be interchangeable for grid construction. Anything that builds a grid on one device and compares against, or injects into, a grid built on the other can silently mismatch — including test oracles, which is exactly how this surfaced. It is quiet: no error is raised and the voxel counts agree, so a caller has no signal that anything is wrong.
Possible directions
Not obvious which is right, so listing rather than recommending:
VoxelCoordTransform::apply(or the callers that feed.round()) so both sides evaluate identically. Cheapest fix if contraction is confirmed as the cause, but needs verification rather than assumption.at::opmath_type<c10::Half>isfloat; promoting todoublefor the coordinate computation would put the boundary cases far away from a tie. Costs throughput on a path that is otherwise memory-bound..round()does with a value that is exactly.5after two different roundings.float16points. Cheapest, but leaves the trap in place.Option 1 is worth measuring first — if contraction is the whole story it is a one-line change with no throughput cost.