Skip to content

fix(arange): correct CPU length calc + GPU step scaling (#1076)#1077

Merged
yingjerkao merged 2 commits into
masterfrom
fix/1076-arange-length
Jul 17, 2026
Merged

fix(arange): correct CPU length calc + GPU step scaling (#1076)#1077
yingjerkao merged 2 commits into
masterfrom
fix/1076-arange-length

Conversation

@yingjerkao

Copy link
Copy Markdown
Collaborator

Problem

arange correctness needed a coordinated CPU + GPU fix (#1076). Two independent bugs, sharing one broken length calculation:

  1. CPU front-end (src/Generator.cpp::arange) — computed the element count as integer truncation of (end-start)/step plus a fmod(...) > 1.0e-14 bump. Per @ianmccul's fix(utils): honor non-unit step in GPU arange for real dtypes (#1070) #1071 review this was "neither a reliable ceil nor a stable half-open test": fmod(1.0, 0.1) ≈ 0.1 (not 0) so decimal steps included the endpoint; the fixed 1e-14 absolute threshold miscounted at small scales (arange(0, 1e-15, 2e-15) computed 0 elements and threw instead of yielding [0]); and empty/direction-mismatched ranges threw despite zero-extent tensors being supported.
  2. GPU kernel (cuSetArange_gpu.cu) — the generic real-dtype kernel dropped the parentheses (start + step * blk * dim + thread), so the per-thread offset was added un-scaled by step; arange(10, 40, 10) on Device.cuda gave [10, 11, 12] instead of [10, 20, 30] (GPU arange ignores non-unit step for real dtypes (missing parentheses in cuSetArange_kernel) #1070). The linear index was also 32-bit and overflows past 2^32 elements.

The front-end computes the count once and feeds it to both backends, so the CPU length bug propagated to the GPU path too.

Fix

  • CPU: Nelem = ceil((end - start) / step), clamped to 0 for an empty/direction-mismatched range (returns a zero-extent tensor). Guards added for zero step, non-finite start/end/step, and overflow of the double → uint64 count. arange(Nelem) made consistent (0 → empty, negative → error).
  • GPU: compute the linear index once as 64-bit and use start + step * idx in all real and complex kernels.

⚠️ Behavior change (please review)

This changes user-visible numeric behavior, as #1076 intended:

  • half-open [start, end) endpoint handling now via ceil-of-count;
  • small-scale ranges count correctly (e.g. arange(0, 1e-15, 2e-15)[0]);
  • empty / direction-mismatched ranges return a zero-extent tensor instead of throwing.

One existing test (DenseUniTensorTest.arange_step_error) asserted the old throw-on-direction-mismatch; it's updated to expect a zero-extent result and renamed.

Testing

  • CPU (tests/arange_test.cpp, new): independent hand-computed values — half-open integer/fractional/negative-step, the 1e-15 small-scale case, ULP boundaries via nextafter(end, ±inf), zero-extent empties, and the step/finite guards. Bug-targeting cases were confirmed to fail on the pre-fix code. Full CPU suite: 1801 passed, 0 failed.
  • GPU (tests/gpu/arange_test.cpp, new): independent literal values (not the CPU path as oracle, per Ian) — non-unit step across Int64/Int32/Double/Float, a >512-element multi-block case exercising the block-boundary index (value[i] == 3*i), fractional step, zero-extent empty. Run locally on an RTX 4070 Ti SUPER (CUDA 13, sm_89). GPU tests don't run in CI (no GPU runner).

Not in scope: the remaining #1076 items (e.g. the <cuda/std/complex> install/PUBLIC-propagation for GPU consumers) belong to #1067.

Closes #1076. Fixes the GPU half of #1070.

yingjerkao and others added 2 commits July 17, 2026 15:17
…extent (#1076)

Problem: the arange(start, end, step) front-end computed the element count as
integer truncation of (end-start)/step plus a `fmod(...) > 1.0e-14` bump. That
was neither a reliable ceil nor a stable half-open [start, end) test: `fmod(1.0,
0.1) ~= 0.1` (not 0) so familiar decimal steps included the endpoint, and the
fixed 1e-14 absolute threshold miscounted at small scales (arange(0, 1e-15,
2e-15) computed 0 elements and threw instead of yielding the single value 0).
Empty and direction-mismatched ranges threw, even though zero-extent tensors are
supported. The same count feeds both CPU and GPU kernels.

Fix (per @ianmccul's #1071 review, folded into #1076): compute the half-open
count as `Nelem = ceil((end - start) / step)`, clamped to 0. A non-positive
count is an empty/direction-mismatched range and returns a zero-extent tensor
rather than throwing. Added guards for zero step, non-finite start/end/step, and
overflow of the double->uint64 count. The arange(Nelem) wrapper is made
consistent: Nelem == 0 yields an empty tensor, negative still errors.

Behavior change (flagged for review): endpoint handling under the half-open
contract, small-scale counts, and empty range -> zero-extent instead of throw.

Testing: new tests/arange_test.cpp with independent hand-computed values --
half-open integer/fractional/negative-step, the 1e-15 small-scale case, ULP
boundaries via nextafter(end, +/-inf), zero-extent empties, and the step/finite
guards. Bug-targeting cases were confirmed to fail on the pre-fix code. Updated
the one existing test that asserted the old throw-on-direction-mismatch to expect
a zero-extent result. Full CPU suite: 1801 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1070/#1076)

Problem: the generic real-dtype cuSetArange_kernel dropped the parentheses in the
value expression -- `start + step * blockIdx.x * blockDim.x + threadIdx.x` --
so the intra-block offset threadIdx.x was added un-scaled by step, and for a
single block the values collapsed to `start + threadIdx.x`. arange(10, 40, 10)
on Device.cuda produced [10, 11, 12] instead of [10, 20, 30] (#1070). The
complex overloads already had the parentheses. Separately, the linear index
`blockIdx.x * blockDim.x + threadIdx.x` was 32-bit and overflows past 2^32
elements.

Fix: compute the linear index once as 64-bit (uint64_t) in all real and complex
kernels, and use `start + step * idx` so the full index is scaled by step.

Testing: new tests/gpu/arange_test.cpp with independent hand-computed values
(not compared against the CPU path): non-unit step across Int64/Int32/Double/
Float, a >512-element multi-block case exercising the block-boundary index
(value[i] == 3*i), fractional step, and a zero-extent empty range. GoogleTest
names avoid underscores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the arange implementation for both CPU and GPU to support zero-extent tensors (empty ranges) and fixes several bugs, including 32-bit index overflow in GPU kernels and incorrect step scaling. It also adds comprehensive unit tests for arange on CPU and GPU. The review feedback highlights critical issues regarding floating-point precision: first, std::ceil can include the exclusive endpoint due to roundoff errors (e.g., arange(0.5, 0.8, 0.1)), which can be resolved by subtracting a small epsilon; second, casting std::numeric_limits<cytnx_uint64>::max() to double rounds up to 2^64, leading to potential undefined behavior when checking for overflow, which can be fixed by using an explicit threshold. Additionally, a test case for non-binary fractional steps is suggested to verify floating-point boundary behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/Generator.cpp
Comment on lines +61 to +62
if (count > 0) {
const cytnx_double nelem = std::ceil(count);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using pure std::ceil(count) can lead to an extra element (including the exclusive endpoint) due to floating-point roundoff errors. For example, with arange(0.5, 0.8, 0.1), count is computed as 3.0000000000000004 instead of exactly 3.0. Taking std::ceil of this rounds it up to 4.0, causing the exclusive endpoint 0.8 to be incorrectly included in the output.

To prevent this, we should subtract a small epsilon that scales with count before applying std::ceil.

    if (count > 0) {
      const cytnx_double eps = count * std::numeric_limits<cytnx_double>::epsilon() * 100.0;
      const cytnx_double nelem = std::ceil(count - eps);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure, I don't think numpy implementation does that adjustment.

$ python3
Python 3.12.3 (main, Jun 19 2026, 12:46:00) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import numpy as np
>>> print(np.arange(0.5, 0.8, 0.1))
[0.5 0.6 0.7 0.8]

Comment thread src/Generator.cpp
Comment on lines +63 to +67
cytnx_error_msg(
nelem > static_cast<cytnx_double>(std::numeric_limits<cytnx_uint64>::max()),
"[ERROR] arange(start=%f,end=%f,step=%f): the requested number of elements exceeds the "
"maximum representable size.\n",
start, end, step);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

std::numeric_limits<cytnx_uint64>::max() is 2^64 - 1. When cast to double (cytnx_double), it rounds up to 2^64 (18446744073709551616.0) because double only has 53 bits of precision.

As a result, the condition nelem > static_cast<cytnx_double>(max) is equivalent to nelem > 2^64. If nelem is exactly 2^64, this condition evaluates to false, bypassing the error check. However, casting 2^64 to uint64_t on the next line is undefined behavior in C++ because it exceeds the maximum representable value of uint64_t (2^64 - 1).

Using nelem >= 18446744073709551616.0 (which is exactly 2^64) avoids this undefined behavior.

Suggested change
cytnx_error_msg(
nelem > static_cast<cytnx_double>(std::numeric_limits<cytnx_uint64>::max()),
"[ERROR] arange(start=%f,end=%f,step=%f): the requested number of elements exceeds the "
"maximum representable size.\n",
start, end, step);
cytnx_error_msg(
nelem >= 18446744073709551616.0,
"[ERROR] arange(start=%f,end=%f,step=%f): the requested number of elements exceeds the "
"maximum representable size.\n",
start, end, step);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If nelem >= 18446744073709551616 I think you've got bigger problems than overflowing a uint64_t...

Comment thread tests/arange_test.cpp
EXPECT_DOUBLE_EQ(t.storage().at<cytnx::cytnx_double>(1), 0.25);
EXPECT_DOUBLE_EQ(t.storage().at<cytnx::cytnx_double>(2), 0.5);
EXPECT_DOUBLE_EQ(t.storage().at<cytnx::cytnx_double>(3), 0.75);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding a test case for non-binary fractional steps (like arange(0.5, 0.8, 0.1)) ensures that floating-point inaccuracies near integer boundaries do not cause the exclusive endpoint to be incorrectly included.

  }

  TEST(Arange, FractionalStepNonBinary) {
    // [0.5, 0.6, 0.7]; 0.8 is the exclusive endpoint.
    // This tests that floating-point inaccuracies near integer boundaries
    // do not cause the exclusive endpoint to be incorrectly included.
    auto t = cytnx::arange(0.5, 0.8, 0.1, Type.Double);
    ASSERT_EQ(t.shape()[0], 3u);
    EXPECT_DOUBLE_EQ(t.storage().at<cytnx::cytnx_double>(0), 0.5);
    EXPECT_DOUBLE_EQ(t.storage().at<cytnx::cytnx_double>(1), 0.6);
    EXPECT_DOUBLE_EQ(t.storage().at<cytnx::cytnx_double>(2), 0.7);
  }

@yingjerkao
yingjerkao requested a review from ianmccul July 17, 2026 07:26

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1b3be3faf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Generator.cpp
const cytnx_double count = (end - start) / step;
cytnx_uint64 Nelem = 0;
if (count > 0) {
const cytnx_double nelem = std::ceil(count);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid ceilling rounded grid counts

For floating ranges that should land exactly on the endpoint but whose (end - start) / step rounds slightly above an integer, this ceil adds an extra element. For example, arange(0.2, 0.8, 0.2) computes a count of about 3.0000000000000004, so it returns four values and includes 0.8, violating the documented half-open [start, end) contract and changing the tensor shape; this can break reshapes or downstream numerical code. Consider tolerating near-integer counts or validating the generated last value against the endpoint.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but numpy does this as well. https://numpy.org/doc/stable/reference/generated/numpy.arange.html

consistency with numpy is probably more useful here.

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.67%. Comparing base (89325cd) to head (a1b3be3).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1077   +/-   ##
=======================================
  Coverage   72.67%   72.67%           
=======================================
  Files         226      226           
  Lines       28177    28177           
  Branches       71       71           
=======================================
  Hits        20479    20479           
  Misses       7677     7677           
  Partials       21       21           
Flag Coverage Δ
cpp 72.79% <100.00%> (ø)
python 64.13% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
C++ backend 71.89% <100.00%> (ø)
Python bindings 77.70% <ø> (ø)
Python package 64.13% <ø> (ø)
Files with missing lines Coverage Δ
src/Generator.cpp 91.66% <100.00%> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 89325cd...a1b3be3. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ianmccul ianmccul left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some pytests that compare against numpy, and document that we follow the numpy implementation, in particular that the count of elements might include (or even slightly beyond?) the final value in some cases, contrary to the specification but consistent with https://numpy.org/doc/stable/reference/generated/numpy.arange.html

@yingjerkao

Copy link
Copy Markdown
Collaborator Author

Maybe we should remove gemini-code from reviewing ..

@yingjerkao
yingjerkao merged commit a16e804 into master Jul 17, 2026
19 checks passed
@yingjerkao
yingjerkao deleted the fix/1076-arange-length branch July 17, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

arange: coordinate CPU front-end + GPU kernel correctness fix (folds #1071 review)

2 participants