-
Notifications
You must be signed in to change notification settings - Fork 36
Widen ndarray linear index to int64 #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
364b86d
1de096c
50e90dc
50c6556
af6db19
55ab538
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,20 @@ def _kernel_coverage_enabled() -> bool: | |
| _arch_cuda = _qd_core.Arch.cuda | ||
| _is_cpython = sys.implementation.name == "cpython" | ||
|
|
||
| # The ndarray ExternalPtrStmt linear index is accumulated in int64 in the LLVM codegen backends | ||
| # (see TaskCodeGenLLVM::visit(ExternalPtrStmt)) and, on the SPIR-V backends (Vulkan/Metal/...), whenever the | ||
| # device advertises 64-bit integers (DeviceCapability.spirv_has_int64 -> TaskCodegen::visit widens to i64). | ||
| # Only a SPIR-V device without shaderInt64 still flattens the offset in int32 and can overflow, so the | ||
| # product-of-dimensions warning below is gated on both the arch and the device capability. | ||
| _ARCHS_WITH_I64_LINEAR_INDEX = frozenset( | ||
| { | ||
| _qd_core.Arch.x64, | ||
| _qd_core.Arch.arm64, | ||
| _qd_core.Arch.cuda, | ||
| _qd_core.Arch.amdgpu, | ||
| } | ||
| ) | ||
|
|
||
| # PERF: Frozen-dataclass dispatch caching. | ||
| # | ||
| # When a frozen dataclass (e.g. Genesis's StructConstraintState with ~43 fields) is passed to a kernel, the per-launch | ||
|
|
@@ -727,14 +741,28 @@ def _recursive_set_args( | |
| # array shapes. | ||
| is_soa = needed_arg_type.layout == Layout.SOA | ||
| array_shape = v.shape | ||
| if math.prod(array_shape) > np.iinfo(np.int32).max: | ||
| warnings.warn("Ndarray index might be out of int32 boundary but int64 indexing is not supported yet.") | ||
| needed_arg_dtype = needed_arg_type.dtype | ||
| if needed_arg_dtype is None or id(needed_arg_dtype) in primitive_types.type_ids: | ||
| element_dim = 0 | ||
| else: | ||
| element_dim = needed_arg_dtype.ndim | ||
| array_shape = v.shape[element_dim:] if is_soa else v.shape[:-element_dim] | ||
| if any(dim > np.iinfo(np.int32).max for dim in array_shape): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this runs on Vulkan/Metal, ndarray addressing is still flattened in SPIR-V as an i32 value ( Useful? React with 👍 / 👎. |
||
| warnings.warn("Ndarray dimensions above int32 are not supported yet.") | ||
| elif ( | ||
| impl.current_cfg().arch not in _ARCHS_WITH_I64_LINEAR_INDEX | ||
| and math.prod(v.shape) > np.iinfo(np.int32).max | ||
| and not impl.get_runtime().prog.get_device_caps().get(_qd_core.DeviceCapability.spirv_has_int64) | ||
| ): | ||
| # No single dimension overflows int32, but the flattened element count does. The LLVM backends | ||
| # accumulate the linear index in int64, and the SPIR-V backends do too when the device advertises | ||
| # shaderInt64. Only a SPIR-V device without shaderInt64 still flattens in int32 and would overflow, | ||
| # so warn (this is a cold path -- only huge ndarrays on a non-i64 device reach here). | ||
| warnings.warn( | ||
| "Ndarray total element count exceeds the int32 boundary; this device lacks 64-bit integer " | ||
| "support (shaderInt64), so the linear index is computed in int32 and may overflow. int64 " | ||
| "linear indexing requires the LLVM backends (CPU/CUDA/AMDGPU) or a SPIR-V device with shaderInt64." | ||
| ) | ||
| if isinstance(v, np.ndarray): | ||
| # Check ndarray flags is expensive (~250ns), so it is important to order branches according to hit stats | ||
| if v.flags.c_contiguous: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1773,26 +1773,33 @@ void TaskCodeGenLLVM::visit(ExternalPtrStmt *stmt) { | |
| int num_array_args = num_indices - num_element_indices; | ||
| const size_t element_shape_index_offset = num_array_args; | ||
|
|
||
| auto *i64_ty = llvm::Type::getInt64Ty(*llvm_context); | ||
| for (int i = 0; i < num_array_args; i++) { | ||
| auto raw_arg = builder->CreateGEP( | ||
| struct_type, llvm_val[stmt->base_ptr], | ||
| {tlctx->get_constant(0), tlctx->get_constant(TypeFactory::SHAPE_POS_IN_NDARRAY), tlctx->get_constant(i)}); | ||
| raw_arg = builder->CreateLoad(tlctx->get_data_type(PrimitiveType::i32), raw_arg); | ||
| sizes[i] = raw_arg; | ||
| {tlctx->get_constant(0), | ||
| tlctx->get_constant(TypeFactory::SHAPE_POS_IN_NDARRAY), | ||
| tlctx->get_constant(i)}); | ||
| raw_arg = | ||
| builder->CreateLoad(tlctx->get_data_type(PrimitiveType::i32), raw_arg); | ||
| sizes[i] = builder->CreateSExt(raw_arg, i64_ty); | ||
| } | ||
|
|
||
| auto linear_index = tlctx->get_constant(0); | ||
| auto linear_index = tlctx->get_constant(get_data_type<int64>(), 0); | ||
| size_t size_var_index = 0; | ||
| for (int i = 0; i < num_indices; i++) { | ||
| if (i >= element_shape_index_offset && i < element_shape_index_offset + num_element_indices) { | ||
| // Indexing TensorType-elements | ||
| llvm::Value *size_var = tlctx->get_constant(stmt->element_shape[i - element_shape_index_offset]); | ||
| llvm::Value *size_var = tlctx->get_constant( | ||
| get_data_type<int64>(), | ||
| stmt->element_shape[i - element_shape_index_offset]); | ||
| linear_index = builder->CreateMul(linear_index, size_var); | ||
| } else { | ||
| // Indexing array dimensions | ||
| linear_index = builder->CreateMul(linear_index, sizes[size_var_index++]); | ||
| } | ||
| linear_index = builder->CreateAdd(linear_index, llvm_val[stmt->indices[i]]); | ||
| auto index = builder->CreateSExtOrBitCast(llvm_val[stmt->indices[i]], i64_ty); | ||
| linear_index = builder->CreateAdd(linear_index, index); | ||
|
Comment on lines
+1801
to
+1802
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This change expands the user-visible ndarray indexing range beyond Useful? React with 👍 / 👎. |
||
| } | ||
| QD_ASSERT(size_var_index == num_indices - num_element_indices); | ||
|
|
||
|
|
@@ -1808,7 +1815,7 @@ void TaskCodeGenLLVM::visit(ExternalPtrStmt *stmt) { | |
| if (operand_dtype->is<TensorType>()) { | ||
| // Access PtrOffset via: base_ptr + offset * sizeof(element) | ||
|
|
||
| auto address_offset = builder->CreateSExt(linear_index, llvm::Type::getInt64Ty(*llvm_context)); | ||
| auto address_offset = linear_index; | ||
|
|
||
| auto stmt_ret_type = stmt->ret_type.ptr_removed(); | ||
| if (stmt_ret_type->is<TensorType>()) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import re | ||
|
|
||
| import numpy as np | ||
| import psutil | ||
| import pytest | ||
|
|
||
| import quadrants as qd | ||
|
|
||
| from tests import test_utils | ||
|
|
||
| # ExternalPtrStmt flattens an N-D ndarray access into a single linear element | ||
| # index: for a 2-D array of shape (D0, D1) the codegen emits | ||
| # linear = i * D1 + j | ||
| # (see TaskCodeGenLLVM::visit(ExternalPtrStmt) in codegen/llvm/codegen_llvm.cpp). | ||
| # Before this fix that accumulation was done in i32 and only sign-extended to | ||
| # i64 for the final GEP, so any array with more than 2**31 elements overflowed | ||
| # *before* the extend and produced a wrong (often negative) address. | ||
| # | ||
| # The smallest 2-D shape that pushes the last valid linear index past INT32_MAX: | ||
| # shape = (2, 2**30 + 1) -> last index [1, 2**30] -> linear = 2**31 + 1 | ||
| _D1 = 2**30 + 1 | ||
| _I = 1 | ||
| _J = 2**30 | ||
| _TRUE_LINEAR = _I * _D1 + _J # == 2**31 + 1, exceeds np.iinfo(np.int32).max | ||
|
|
||
|
|
||
| @test_utils.test(arch=[qd.cpu]) | ||
| def test_i32_linear_index_overflows_but_i64_is_correct(): | ||
| # Reproduces the exact arithmetic ExternalPtrStmt performs. The i32 kernel | ||
| # mirrors the pre-fix codegen and must wrap to a wrong value; the i64 kernel | ||
| # mirrors the fix and must stay correct. | ||
| @qd.kernel | ||
| def linear_i32(d1: qd.i32, i: qd.i32, j: qd.i32) -> qd.i32: | ||
| return i * d1 + j | ||
|
|
||
| @qd.kernel | ||
| def linear_i64(d1: qd.i64, i: qd.i64, j: qd.i64) -> qd.i64: | ||
| return i * d1 + j | ||
|
|
||
| assert _TRUE_LINEAR > np.iinfo(np.int32).max | ||
|
|
||
| # Pre-fix behavior: i32 accumulation overflows and wraps to a negative offset. | ||
| overflowed = linear_i32(_D1, _I, _J) | ||
| assert overflowed != _TRUE_LINEAR | ||
| assert overflowed < 0 | ||
|
|
||
| # Post-fix behavior: i64 accumulation yields the true linear index. | ||
| assert linear_i64(_D1, _I, _J) == _TRUE_LINEAR | ||
|
|
||
|
|
||
| # ~2 GB for the int8 backing array plus headroom for the device-side copy. | ||
| _REQUIRED_BYTES = 5 * 1024**3 | ||
|
|
||
|
|
||
| @pytest.mark.skipif( | ||
| psutil.virtual_memory().available < _REQUIRED_BYTES, | ||
| reason="needs >5 GB RAM to allocate an ndarray with more than 2**31 elements", | ||
| ) | ||
| @test_utils.test(arch=[qd.cpu]) | ||
| def test_ndarray_read_past_int32_index_boundary(): | ||
| # End-to-end regression guard: on the pre-fix codegen the i32 linear index | ||
| # for [1, 2**30] wraps negative and this read returns garbage / segfaults. | ||
| @qd.kernel | ||
| def read(arr: qd.types.NDArray[qd.i8, 2], i: qd.i32, j: qd.i32) -> qd.i8: | ||
| return arr[i, j] | ||
|
|
||
| np_arr = np.zeros((2, _D1), dtype=np.int8) | ||
| sentinel = np.int8(7) | ||
| np_arr[_I, _J] = sentinel | ||
|
|
||
| assert read(np_arr, _I, _J) == sentinel | ||
|
|
||
|
|
||
| @test_utils.test(arch=[qd.cpu]) | ||
| def test_ndarray_external_ptr_uses_i64_linear_index(): | ||
| @qd.kernel | ||
| def read(arr: qd.types.NDArray[qd.i32, 2], i: qd.i32, j: qd.i32) -> qd.i32: | ||
| return arr[i, j] | ||
|
|
||
| np_arr = np.arange(16, dtype=np.int32).reshape(4, 4) | ||
| assert read(np_arr, 1, 2) == np_arr[1, 2] | ||
|
|
||
| compiled = read._primal._last_compiled_kernel_data | ||
| assert compiled is not None | ||
|
|
||
| llvm_ir = compiled._debug_dump_to_string() | ||
|
|
||
| assert re.search(r"sext i32 .* to i64", llvm_ir) | ||
| assert "mul nsw i64" in llvm_ir | ||
| assert re.search(r"getelementptr i32, ptr .* i64 ", llvm_ir) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wait, there was already a warning?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes — main had an unconditional one here: warnings.warn("...int64 indexing is not supported yet."). But it was only a warning (the offset still overflowed and returned the wrong element), and its message is now false for CPU/CUDA/AMDGPU since those accumulate in int64. Keeping it as-is would warn on exactly the backends this PR fixes. So I kept a warning but scoped it to the backends that still flatten in int32 — and with the new SPIR-V int64 commit, only to devices without shaderInt64.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer to just keep the warning as-is, without changing anything, without a strong reason.
What is your motivation for creating this PR? I'm sensing the motivation is primarily 'this is broken and it should be fixed'?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I'm happy to upgrade from warning to throwing an exception, if that would make you more comfortable?)
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is the motivation from a colleague of mine who worked on this:
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick update, and a correction to my earlier comment. When I said genesis-world doesn't hit this — that physics slows down before an ndarray crosses ~2.1B elements — I was reasoning from my own assumptions. Since then a colleague who worked on the original change filled me in: they did hit it in practice, on a real workload running >32k parallel envs, where the aggregate ndarray crosses INT32_MAX even though the per-env tensors are modest. So there is a concrete, non-artificial workload behind this, not just the memory-safety argument — I was wrong to imply nobody needed the capability.
That also speaks to the warning-vs-fix question. For that workload, neither the existing warning nor upgrading it to an exception helps — it needs to run at that size, and that requires the flattened-offset arithmetic not to overflow. I'm fully on board with your instinct that silent is the worst case: making overflow throw instead of warn is strictly better where we can't support the size (e.g. a device without shaderInt64). But where we can support it, the fix lets the workload actually execute rather than failing loudly.
So the two motivations are complementary:
And the SPIR-V commit (55ab538) extends the same narrow widening to Vulkan/Metal (capability-gated on shaderInt64, i32 + warning fallback otherwise), which is the cross-backend parity you asked for — so no backend is left silently overflowing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmmm, this is ndarray only. what happens if we change between ndarray and field? (we do this in genesis-world for example: ndarray for development mode, compiles quickly; field for performance mode, runs quickly, but compiles slowly). Will this introduce a correctness disparity between ndarray and field?