Summary
JaggedTensor.from_data_offsets_and_list_ids is public in both C++ (src/fvdb/JaggedTensor.cpp:496) and Python (fvdb/jagged_tensor.py:702, bound at src/python/JaggedTensorBinding.cpp:73), but it does not validate the offsets it is handed. It checks only:
list_ids.dim() == 2
list_ids.numel() == 0 || list_ids.size(0) == offsets.size(0) - 1
offsets.dim() == 1
It does not check that offsets are non-negative, non-decreasing, or within the bounds of data. check_valid() (src/fvdb/JaggedTensor.h:661) doesn't either — it only validates shapes, devices and dtypes.
The result is that a JaggedTensor with a structurally invalid joffsets can be constructed from pure Python without error, and the failure surfaces much later as an out-of-bounds read inside whichever op consumes it.
Reproducer
import torch
from fvdb import GridBatch, JaggedTensor
data = torch.randn(100, 3, device="cuda")
offs = torch.tensor([0, 80, 20, 100], dtype=torch.int64, device="cuda") # decreasing
lids = torch.arange(3, dtype=torch.int32, device="cuda").reshape(-1, 1)
jt = JaggedTensor.from_data_offsets_and_list_ids(data, offs, lids)
torch.cuda.synchronize()
print(jt.joffsets.tolist()) # [0, 80, 20, 100] -- constructed and synchronized, no error
GridBatch.from_points(jt, voxel_sizes=0.1, origins=0.0)
Observed, with GridBatch.from_points as the consumer:
joffsets |
result |
[0, 80, 20, 100] (decreasing) |
CUDA error 2: out of memory (nanovdb/cuda/DeviceResource.h:35) |
[0, -50, 100] (negative) |
CUDA error 2: out of memory |
[0, 50, 10000000] (past end of data) |
CUDA error 700: an illegal memory access was encountered (nanovdb/tools/cuda/PointsToGrid.cuh:629) |
The mechanism for the first two: consumers derive a per-item count as offsets[i+1] - offsets[i], which goes negative and then converts to a huge value when passed to an API taking a size_t count. The third reads past the end of the data buffer.
Note the construction itself completes and survives a cuda.synchronize() — the tensor looks fine until something uses it.
Scope
This is not specific to from_points. Any op that slices jdata using consecutive joffsets entries is affected; _createNanoGridFromIJK (src/fvdb/detail/ops/BuildGridFromIjk.cu:65-75) has the identical startIdx / nVoxels pattern, and that shape is common across the ops. Verified reproducible on main — this is long-standing, not a regression from any recent change.
Where the fix belongs
Raised during review of #719, where the suggestion was to add host-side bounds validation inside the op. That was reverted deliberately: individual ops should be able to trust that a JaggedTensor they are handed is structurally valid, rather than each one re-deriving that check. Pushing it into every consumer multiplies the same logic across the codebase and still leaves any op that forgets it exposed.
That from_jdata_joffsets_jidx_and_lidx_unsafe exists and is explicitly named "unsafe" (and is not exposed to Python) suggests the intent is already that the non-unsafe constructors are safe — they just aren't yet.
Design consideration
Validating offsets means inspecting their values, and offsets typically lives on the device, so a naive check adds a device-to-host sync to every construction. Worth deciding between:
- Validate in the public constructors only (
from_data_offsets_and_list_ids, from_data_indices_and_list_ids), accepting one sync at construction, and keep ..._unsafe as the no-check escape hatch for internal hot paths.
- Validate with a small device-side kernel that sets an error flag, avoiding the sync but deferring the error.
- Add an explicit
validate() and document that the raw-structure constructors are unchecked — cheapest, but leaves the public Python API able to produce a tensor that crashes the process.
Option 1 seems most consistent with the existing unsafe naming, but the sync cost on batched construction paths is worth measuring first.
Summary
JaggedTensor.from_data_offsets_and_list_idsis public in both C++ (src/fvdb/JaggedTensor.cpp:496) and Python (fvdb/jagged_tensor.py:702, bound atsrc/python/JaggedTensorBinding.cpp:73), but it does not validate theoffsetsit is handed. It checks only:list_ids.dim() == 2list_ids.numel() == 0 || list_ids.size(0) == offsets.size(0) - 1offsets.dim() == 1It does not check that offsets are non-negative, non-decreasing, or within the bounds of
data.check_valid()(src/fvdb/JaggedTensor.h:661) doesn't either — it only validates shapes, devices and dtypes.The result is that a
JaggedTensorwith a structurally invalidjoffsetscan be constructed from pure Python without error, and the failure surfaces much later as an out-of-bounds read inside whichever op consumes it.Reproducer
Observed, with
GridBatch.from_pointsas the consumer:joffsets[0, 80, 20, 100](decreasing)CUDA error 2: out of memory(nanovdb/cuda/DeviceResource.h:35)[0, -50, 100](negative)CUDA error 2: out of memory[0, 50, 10000000](past end of data)CUDA error 700: an illegal memory access was encountered(nanovdb/tools/cuda/PointsToGrid.cuh:629)The mechanism for the first two: consumers derive a per-item count as
offsets[i+1] - offsets[i], which goes negative and then converts to a huge value when passed to an API taking asize_tcount. The third reads past the end of the data buffer.Note the construction itself completes and survives a
cuda.synchronize()— the tensor looks fine until something uses it.Scope
This is not specific to
from_points. Any op that slicesjdatausing consecutivejoffsetsentries is affected;_createNanoGridFromIJK(src/fvdb/detail/ops/BuildGridFromIjk.cu:65-75) has the identicalstartIdx/nVoxelspattern, and that shape is common across the ops. Verified reproducible onmain— this is long-standing, not a regression from any recent change.Where the fix belongs
Raised during review of #719, where the suggestion was to add host-side bounds validation inside the op. That was reverted deliberately: individual ops should be able to trust that a
JaggedTensorthey are handed is structurally valid, rather than each one re-deriving that check. Pushing it into every consumer multiplies the same logic across the codebase and still leaves any op that forgets it exposed.That
from_jdata_joffsets_jidx_and_lidx_unsafeexists and is explicitly named "unsafe" (and is not exposed to Python) suggests the intent is already that the non-unsafeconstructors are safe — they just aren't yet.Design consideration
Validating offsets means inspecting their values, and
offsetstypically lives on the device, so a naive check adds a device-to-host sync to every construction. Worth deciding between:from_data_offsets_and_list_ids,from_data_indices_and_list_ids), accepting one sync at construction, and keep..._unsafeas the no-check escape hatch for internal hot paths.validate()and document that the raw-structure constructors are unchecked — cheapest, but leaves the public Python API able to produce a tensor that crashes the process.Option 1 seems most consistent with the existing
unsafenaming, but the sync cost on batched construction paths is worth measuring first.