Skip to content

refactor(pybind)!: remove the cytnx.Scalar Python binding (#1045) - #1104

Merged
yingjerkao merged 1 commit into
masterfrom
fix/1045-remove-scalar-python-binding
Jul 22, 2026
Merged

refactor(pybind)!: remove the cytnx.Scalar Python binding (#1045)#1104
yingjerkao merged 1 commit into
masterfrom
fix/1045-remove-scalar-python-binding

Conversation

@yingjerkao

Copy link
Copy Markdown
Collaborator

Problem

cytnx.Scalar is a C++ type-erasure helper that adds nothing on the Python surface — there is no native float32, and a cytnx.Scalar object is worse ergonomics than a plain Python number whose precision the caller already knows from the tensor's dtype. Direction confirmed by @yingjerkao in #1045.

Fix

  • Remove pybind/scalar_py.cpp and its declaration/registration in pybind/cytnx.cpp and the CMake source list.
  • Drop the redundant const Scalar& overloads from every Tensor/UniTensor arithmetic/comparison/__setitem__ keep-set. Native int/float/complex and numpy scalars are already handled by sibling overloads, and with Scalar unregistered nothing in Python can produce a cytnx.Scalar to match them — so removal is behaviour-preserving for all native inputs.
  • Migrate linalg Ger/Gemm/Gemm_/Lanczos_Exp from Scalar coefficient args to native double/complex128 overloads. Ger keeps a no-a form so the C++ Void-default (which keeps a out of output-dtype promotion, see src/linalg/Ger.cpp) is preserved exactly.
  • Migrate pytests off cytnx.Scalar(...); regenerate linalg.pyi and surgically drop Scalar from __init__.pyi.

Scope note: the rest of __init__.pyi is independently stale (it predates the operator keep-set collapse, #916/#928, and was never regenerated). A full regeneration produces a ~2600-line diff of unrelated churn, so this PR only removes the Scalar entries and leaves the broader stub reconciliation for a dedicated stub-hygiene pass.

C++ Scalar is unaffected — this is purely the Python binding surface.

Testing

  • openblas-cpu pycytnx build clean.
  • pytest pytests/139 passed, 1 skipped.
  • Verified cytnx.Scalar is gone and native int/float/complex/numpy arithmetic (both directions), floordiv, and Ger/Gemm real+complex coefficients still work.

Closes #1045. Refs #1000.

🤖 Generated with Claude Code

@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 removes the cytnx.Scalar class from the Python bindings, replacing its usage with native Python and NumPy scalar types (such as cytnx_double and cytnx_complex128) across Tensor, UniTensor, and linear algebra functions like Gemm, Gemm_, Ger, and Lanczos_Exp. Feedback was provided to release the GIL in the newly added Ger overloads to maintain consistency with other computationally intensive linear algebra operations.

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 pybind/linalg_py.cpp
Comment on lines +914 to +915
"Ger", [](const Tensor &x, const Tensor &y) { return cytnx::linalg::Ger(x, y); }, py::arg("x"),
py::arg("y"));

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

For consistency with other linear algebra operations that can be computationally expensive, consider releasing the GIL here. Ger performs an outer product, which can be intensive for large tensors. This should be applied to all three Ger overloads.

Suggested change
"Ger", [](const Tensor &x, const Tensor &y) { return cytnx::linalg::Ger(x, y); }, py::arg("x"),
py::arg("y"));
"Ger", [](const Tensor &x, const Tensor &y) { return cytnx::linalg::Ger(x, y); }, py::arg("x"),
py::arg("y"), py::call_guard<py::gil_scoped_release>());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

For context: Ger's binding didn't release the GIL on master either, so this isn't introduced by the Scalar removal — I kept the existing behavior to keep the PR scoped. Ger is pure C++ compute (no Python callbacks), so adding py::call_guard<py::gil_scoped_release>() to both Ger overloads is safe; I'd prefer to land it as a small separate consistency change (a couple of other linalg bindings are in the same boat) rather than widen this PR. Gemm/Gemm_/Lanczos_Exp already release it.

@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: 52f15161da

ℹ️ 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 pybind/linalg_py.cpp
Comment on lines +916 to +918
m_linalg.def(
"Ger",
[](const Tensor &x, const Tensor &y, cytnx_double a) { return cytnx::linalg::Ger(x, y, a); },

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 Preserve narrow numpy coefficient dtypes

For Python callers replacing cytnx.Scalar(np.float32(...)) or cytnx.Scalar(np.complex64(...)) with the underlying numpy scalar, these new coefficient overloads only create Double/ComplexDouble Scalars. Ger and the Gemm variants include a/b in dtype promotion (linalg::Ger, linalg::Gemm_), so Float/ComplexFloat inputs now get promoted to Double/ComplexDouble instead of preserving the narrow coefficient dtype; that changes observable result dtypes and can double memory/work. Please add float/complex64/numpy-scalar coefficient overloads rather than forcing the widest types.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6288186, together with the integer case. The coefficients now go through a single runtime ScalarArg dispatcher (pybind/scalar_arg.hpp) that keeps the caller's numpy width: np.float32Float, np.complex64ComplexFloat, and np.int8/np.uint8Int16/Uint16 (the widths pybind's numpy_scalar<T> set doesn't register, #1065). One overload → one stub signature, per @IvanaGyro's #1066.

One correction to the premise: on origin/master a bare np.float32/np.complex64 coefficient actually raised TypeError here (the accept-Scalar overload's implicit conversions didn't accept them), rather than preserving Float32/Complex64 — so this is now accepted and preserved, not a regression that existed before.

Comment thread pybind/linalg_py.cpp Outdated
yingjerkao added a commit that referenced this pull request Jul 21, 2026
#1045 review)

Addresses @IvanaGyro's review of #1104: the complex overloads of
Gemm/Ger/Gemm_/Lanczos_Exp took a raw cytnx_complex128, so pybind11-stubgen
annotated the parameter as 'SupportsComplex | SupportsFloat | SupportsIndex',
which omits builtin 'complex' under the project's Python 3.10 floor (typeshed's
'complex' only gained __complex__ in 3.11). A stub generated from that caster
rejects a plain complex literal (e.g. Gemm(2+1j, x, y)) at type-check time even
though the runtime caster accepts it. Wrap those parameters in complex_arg.hpp's
ComplexArg -- it delegates load()/cast() to the cytnx_complex128 caster and only
overrides the declared stub annotation to include 'complex' -- matching the
ExpH/ExpM bindings already in this file.

Testing: openblas-cpu pycytnx build; verified Gemm/Ger/Gemm_/Lanczos_Exp accept
plain Python complex literals at runtime with the real overloads unchanged;
regenerated linalg.pyi (the four complex annotations now include 'complex').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yingjerkao

Copy link
Copy Markdown
Collaborator Author

Addressed @IvanaGyro's review in 5893f30: the complex overloads of Gemm/Ger/Gemm_/Lanczos_Exp now take complex_arg.hpp's ComplexArg instead of a raw cytnx_complex128, so the generated stub parameter annotation includes builtin complex (fixing the Python 3.10 type-check rejection of e.g. Gemm(2+1j, x, y)), matching the ExpH/ExpM bindings in the same file. Runtime behaviour is unchanged — verified all four accept plain Python complex literals; linalg.pyi regenerated for the four signatures.

Heads-up: this branch is ~117 commits behind master now (it predates the complex_arg.hpp infrastructure landing), so it'll need a rebase/merge before it lands — I kept this fix focused rather than folding in a large merge.

Problem: cytnx.Scalar leaked C++'s Scalar value type onto the Python
surface. Python code should work with native numbers and derive dtype
from a tensor/storage's dtype(), never handle a Scalar object.

Fix:
- Drop the Scalar binding entirely (delete pybind/scalar_py.cpp, its
  scalar_binding() decl/call in cytnx.cpp, and its CMakeLists entry).
- Remove the redundant accept-Scalar arithmetic/compare overloads from
  Tensor (tensor_py.cpp) and UniTensor (unitensor_py.cpp). Each has a
  native double/complex128 sibling plus the numpy_scalar/py::int_ keep
  set, so plain Python numbers still dispatch unchanged -- including
  scalar UniTensor '//' and '%', kept per #1049.
- Migrate the linalg coefficients that were Scalar (Ger, Gemm, Gemm_,
  Lanczos_Exp) to native real overloads plus a complex_arg.hpp
  ComplexArg fallback, matching the existing ExpH/ExpM pattern so a real
  argument stays real and a Python complex is accepted on 3.10.
- Delete the now-obsolete Python Scalar tests (pytests/scalar_variant_test.py
  and the Scalar-constructor cases in keep_set_dispatch_test.py); the C++
  variant behaviour stays covered by tests/Scalar_test.cpp. Update the
  __ne__/reverse-op tests to use native operands.
- Regenerate the committed .pyi stubs (Scalar class removed; Ger/Gemm/
  Gemm_/Lanczos_Exp gain real/complex overloads).

Testing: pytest pytests/ -> 260 passed, 1 skipped. Smoke-checked that
cytnx.Scalar is gone, Tensor/UniTensor scalar arithmetic (incl. ut//2.0),
and Ger/Gemm/Gemm_/Lanczos_Exp accept native ints/floats/complex with the
expected result dtypes. clang-format v14 clean.

Closes #1045.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yingjerkao
yingjerkao force-pushed the fix/1045-remove-scalar-python-binding branch from 5893f30 to 2e96b24 Compare July 21, 2026 02:55
@yingjerkao

Copy link
Copy Markdown
Collaborator Author

Rebased onto current master (2e96b248). The branch was ~117 commits behind, so instead of a literal 3-way rebase (which silently dropped some of the removals — master had moved the accept-Scalar overloads around) I re-derived the change against master's current code and squashed the two prior commits into one (the earlier ComplexArg follow-up is folded in). Result: the branch is now exactly master + one clean commit.

What changed vs. the previous version of this PR, because master moved underneath it:

  • Overload removals re-verified against master. Each removed accept-Scalar arithmetic/compare overload was confirmed to still have a native double/complex128 sibling (plus the numpy_scalar/py::int_ keep-set), so plain-Python-number dispatch is unchanged. In particular master's UniTensor scalar // and % keep-sets retain their native siblings, so ut // 2.0 still works (fix(pybind)!: guard UniTensor//UniTensor floor-division (closes #934 hole from #997) #1049). Tensor // stays deliberately unbound (Its time: Typed Storage Dispatch #941).
  • linalg migration matches master's ExpH/ExpM pattern: Ger/Gemm/Gemm_/Lanczos_Exp now take a native real overload first, then a complex_arg.hpp ComplexArg fallback (accepts builtin complex on 3.10, keeps real→real dtype).
  • New master-era Python-Scalar tests removed. master grew pytests/scalar_variant_test.py (+ Scalar-constructor cases in keep_set_dispatch_test.py) via the refactor(Scalar): replace PIMPL + virtual dispatch with std::variant #847 variant refactor — all exercising the now-removed Python cytnx.Scalar surface. These are deleted; the underlying variant behaviour stays covered by C++ tests/Scalar_test.cpp.
  • Stubs regenerated (pybind11-stubgen): class Scalar gone from __init__.pyi; linalg.pyi gains the real/complex overloads.

Testing on this box (openblas-cpu): pytest pytests/260 passed, 1 skipped; smoke-checked that cytnx.Scalar is gone and Tensor/UniTensor scalar arithmetic + Ger/Gemm/Gemm_/Lanczos_Exp accept native int/float/complex with the expected result dtypes. clang-format v14 clean. (No GPU/C++-ctest impact: the change is confined to the pycytnx pybind target, not libcytnx.)

@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: 2e96b24867

ℹ️ 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 pybind/linalg_py.cpp
Comment on lines +590 to +591
[](cytnx_double a, const Tensor &x, const Tensor &y, cytnx_double b, Tensor &c) {
cytnx::linalg::Gemm_(a, x, y, b, c);

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 Preserve Python-int coefficient dtypes

When callers replace cytnx.Scalar(5) with the native 5, this new coefficient overload routes the value through a Double Scalar instead of the previous int64/uint64 Scalar path. Gemm_/Gemm/Ger include these coefficients in Type.type_promote before running the kernels, so float tensors now get promoted to Double for integer coefficients such as Gemm_(5, x_float, y_float, 0, c_float); add a py::int_ dispatch overload like the tensor keep-sets use so native Python integers keep their integer Scalar dtype instead of widening to Double.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified and fixed in 6288186. You're right — reproduced against a from-scratch build of origin/master: Gemm(5, float, float) was Float, and my earlier cytnx_double overload made it Double (on master an integer coefficient resolved to an int64/uint64 Scalar, which doesn't upcast a Float result).

Rather than a py::int_ overload, the coefficients now go through a single ScalarArg dispatcher (pybind/scalar_arg.hpp) that infers the coefficient's cytnx dtype from the caller's actual Python/numpy type at runtime — the single-dispatcher model @IvanaGyro proposes in #1066, not a per-dtype overload ladder. Result, all matching master again: Gemm(5,f,f) / Gemm(np.int64(5),…) / Gemm(np.int32(5),…) / Ger(f,f,5)Float; 2.0Double; 2+1jComplexDouble. It also closes the np.int8/np.uint8 gap (#1065) and now preserves narrow numpy floats/complex that the old accept-Scalar overload rejected outright.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

#1066 is for list args only. There should be more efficient way for scalar args.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the correction — I over-applied #1066 (which, as you say, is about list args and their mixed-element / empty-list problems) to scalar args, where those problems don't exist. Reverted to the minimal double + ComplexArg binding in 2e96b248.

The only thing left is Codex's note that a bare integer coefficient now widens a Float result to Double (correctness-neutral, and only for the rare Float-tensor + integer-coefficient case). If you do want to preserve the coefficient's dtype, I'll do it with explicit numpy_scalar<T> overloads (the bind_exp_scalar pattern) as a separate, consistent change rather than a dispatcher — just let me know if it's worth it.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 40.74074% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.97%. Comparing base (b367de3) to head (6288186).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
pybind/scalar_arg.hpp 33.33% 44 Missing ⚠️
pybind/linalg_py.cpp 73.33% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1104      +/-   ##
==========================================
- Coverage   73.25%   72.97%   -0.28%     
==========================================
  Files         225      225              
  Lines       27802    27692     -110     
  Branches       71       71              
==========================================
- Hits        20365    20209     -156     
- Misses       7416     7462      +46     
  Partials       21       21              
Flag Coverage Δ
cpp 73.10% <40.74%> (-0.28%) ⬇️
python 64.13% <ø> (ø)

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

Components Coverage Δ
C++ backend 72.55% <ø> (-0.01%) ⬇️
Python bindings 76.07% <40.74%> (-1.63%) ⬇️
Python package 64.13% <ø> (ø)
Files with missing lines Coverage Δ
pybind/cytnx.cpp 100.00% <ø> (ø)
pybind/tensor_py.cpp 71.67% <ø> (+0.29%) ⬆️
pybind/unitensor_py.cpp 74.17% <ø> (-0.23%) ⬇️
pybind/linalg_py.cpp 82.26% <73.33%> (-0.15%) ⬇️
pybind/scalar_arg.hpp 33.33% <33.33%> (ø)

... and 4 files with indirect coverage changes


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 b367de3...6288186. 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.

Comment thread pybind/linalg_py.cpp
Comment on lines +564 to +571
// Ger's scaling coefficient `a` was formerly a cytnx.Scalar; the Python
// surface no longer exposes Scalar, so split it into native real/complex
// overloads plus a no-`a` form. The no-`a` form calls the C++ default
// (Scalar()), whose Void dtype keeps `a` out of the output-dtype promotion
// (see src/linalg/Ger.cpp) -- passing 1.0 explicitly would not reproduce it.
m_linalg.def(
"Ger", [](const Tensor &x, const Tensor &y) { return cytnx::linalg::Ger(x, y); }, py::arg("x"),
py::arg("y"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

BTW, this is not really the BLAS ger.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — the binding just forwards to cytnx::linalg::Ger; the name predates this PR and its semantics are unchanged here. Happy to track a rename separately if it's a misnomer worth fixing.

@yingjerkao

Copy link
Copy Markdown
Collaborator Author

Pushed 62881865 addressing the review. Heads-up since the approval was on the previous commit (2e96b248): this commit changes coefficient-dtype behavior, so it's worth a re-glance.

Codex correctly caught that binding the Ger/Gemm/Gemm_/Lanczos_Exp scaling coefficients as cytnx_double + ComplexArg widened an integer coefficient to Double (verified against a from-scratch origin/master build: Gemm(5, float, float) was Float, became Double). Fixed by taking each coefficient as a single ScalarArg runtime dispatcher (pybind/scalar_arg.hpp) instead of a per-dtype overload — the model @IvanaGyro describes in #1066. It restores the exact old integer/float/complex dtypes, additionally fixes the np.int8/np.uint8 gap (#1065), and preserves narrow numpy float/complex coefficients (which the old accept-Scalar overload rejected with TypeError). One stub signature per function; pytest pytests/ → 260 passed, 1 skipped. Per-thread replies inline.

@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: 62881865c6

ℹ️ 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 pybind/tensor_py.cpp
Comment on lines 735 to 736
.def("__add__",
[](cytnx::Tensor &self, const cytnx::cytnx_double &rhs) { return self.Add(rhs); })

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 Add 8-bit numpy scalar paths after removing Scalar

When callers migrate cytnx.Scalar(np.int8(...))/np.uint8(...) to the underlying numpy scalar, this keep-set has no 8-bit numpy overload and py::int_ does not accept numpy integer scalars, so those operands fall through to the double fallback here. That changes cases like cytnx.zeros([1], dtype=Type.Float) + np.int8(1) from the old integer-Scalar promotion path to a Double result; please add explicit int8/uint8 handling, or route these scalar operands through the same dtype-inspecting ScalarArg used for linalg.

Useful? React with 👍 / 👎.

Comment thread pybind/scalar_arg.hpp Outdated
// Build a cytnx::Scalar from an arbitrary Python number (Python
// bool/int/float/complex, or any numpy scalar), inferring the dtype at
// runtime. Returns false for anything that is not a number.
inline bool load_scalar_arg(pybind11::handle src, cytnx::Scalar &out) {

@IvanaGyro IvanaGyro Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel that this dispatcher is slow. Will explicitly overload for each numpy type be faster?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right — reverted. I've dropped scalar_arg.hpp and put the branch back to 2e96b248 (the commit you approved), so Ger/Gemm/Gemm_/Lanczos_Exp are the simple double + ComplexArg binding again. Agreed the runtime numpy reflection isn't the right tool for a single scalar coefficient.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You're right — reverted. I've dropped scalar_arg.hpp and put the branch back to 2e96b248 (the commit you approved), so Ger/Gemm/Gemm_/Lanczos_Exp are the simple double + ComplexArg binding again. Agreed the runtime numpy reflection isn't the right tool for a single scalar coefficient.

Sorry. It seems that I was wrong.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I investigated pybind11 3.0.4's scalar casters and overload resolution, and benchmarked alternatives with the same shape as Ger(x, y, a) (two bound arguments followed by the scalar).

Conclusion: the concern about the original dispatcher is valid, but explicit external pybind11 overloads for every NumPy scalar type are not generally faster. The dispatcher can be made substantially cheaper without an overload ladder. More importantly, the current reverted head (2e96b248) has a correctness problem and should not be merged unchanged.

Correctness problem in the current double + ComplexArg bindings

The current binding registers the double overload before ComplexArg for Ger, Gemm, Gemm_, and Lanczos_Exp. For np.complex64(3 + 4j):

  1. Neither overload matches during pybind11's no-conversion pass.
  2. During the conversion pass, the earlier double overload invokes PyFloat_AsDouble.
  3. NumPy converts the value to 3.0, emits ComplexWarning, and discards 4j.
  4. The later ComplexArg overload is never tried.

I reproduced this with pybind11 3.0.4. It is especially risky for Lanczos_Exp, where a complex time step can silently become real. The same binding also maps np.float32/integer coefficients to Double and np.complex64 to ComplexDouble; because Ger/Gemm include coefficients in dtype promotion, that can change the result dtype and select double-precision storage/kernels.

This behavior follows from pybind11's floating-point caster, complex caster, and registration-order-based overload resolution.

Why a per-dtype overload ladder is not the efficient solution

Each py::numpy_scalar<T> caster is efficient by itself: it checks a cached NumPy scalar type and extracts with PyArray_ScalarAsCtype (implementation). The cost comes from pybind11's outer overload dispatcher:

  • overloads are attempted sequentially in registration order, potentially in both no-conversion and conversion passes;
  • for Ger(x, y, a), every failed scalar overload also reloads the preceding Tensor arguments;
  • later scalar types become progressively slower;
  • Gemm_ has two independently typed coefficients, so complete explicit coverage becomes combinatorial.

A representative pybind11 3.0.4 microbenchmark gave:

Strategy Approximate total call time
Baseline bound call 0.10 µs
Original dtype.kind/itemsize reflection 0.12 µs for Python numbers; 0.40–0.46 µs for NumPy scalars
One caster backed by exact std::variant alternatives 0.15–0.31 µs
One caster using cached NumPy type-pointer dispatch 0.11–0.15 µs
External overload ladder 0.21 µs for the first type; 0.8–2.2 µs for later types

The absolute values are machine-dependent, but the ordering was stable: an overload ladder only favors whichever type is registered first. The original reflection overhead is only a few tenths of a microsecond and is normally negligible relative to allocating and computing a GER/GEMM result; incorrect promotion to a wider kernel is likely more expensive.

Recommended implementation

Restore a single input-only ScalarArg, but remove Python-level dtype.kind/itemsize reflection. Implement its load() using either:

  1. a std::variant of exact py::numpy_scalar<T> casters followed by py::bool_, py::int_, double, and ComplexArg (simpler and already fast); or
  2. cached exact NumPy scalar type-pointer checks with direct extraction (fastest in the microbenchmark).

The caster should immediately convert the selected value to cytnx::Scalar while the GIL is held; the computational lambda then receives only C++ data, so the existing GIL-release guards remain safe. Put exact NumPy alternatives before broad Python conversions, with Boolean before integer.

This keeps one binding and one union stub signature, preserves np.float32/np.complex64, handles np.int8/np.uint8 by widening them to Cytnx Int16/Uint16, avoids repeated Tensor conversions, and handles the two Gemm_ coefficients independently.

So my recommendation is:

  1. Do fix the correctness/dtype issue; do not optimize it with many pybind11 overloads.
  2. Use one exact-type ScalarArg caster, preferably variant-backed unless profiling justifies cached-pointer code.
  3. Add regression tests for nonzero-imaginary np.complex64, narrow result dtypes, native integers, and mixed Gemm_ coefficient types.
  4. Treat releasing the GIL for Ger as a separate, more meaningful performance improvement.

GPT-5.6 Sol Max

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A correction to my earlier recommendation: yes, for this PR I would prefer the cached NumPy type-pointer caster over the variant-backed caster.

I originally favored the variant because it composes pybind11 casters and avoids depending directly on pybind11::detail. That was too conservative here: Cytnx pins pybind11 == 3.0.4 exactly, the cached implementation is small and isolated, and a future pybind11 bump will compile-test it. The variant remains reasonable, but it is not the best-performing choice shown by the benchmark.

This is worth implementing primarily to restore correct coefficient dtype/complex-value behavior; the dispatch-speed improvement is secondary. I compiled and tested the following core against pybind11 3.0.4 + NumPy 2.3.5 for all 13 standard NumPy scalar types, including np.complex64(3+4j):

#include <pybind11/numpy.h>

template <typename NumpyT, typename CytnxT = NumpyT>
bool try_numpy_scalar_exact(pybind11::handle src, cytnx::Scalar &out) {
  // pybind11's numpy_scalar<T> caster already owns a lazily cached type pointer.
  using caster = pybind11::detail::type_caster<pybind11::numpy_scalar<NumpyT>>;
  auto *expected = reinterpret_cast<PyTypeObject *>(caster::target_type().ptr());
  if (Py_TYPE(src.ptr()) != expected) return false;

  NumpyT value{};
  pybind11::detail::npy_api::get().PyArray_ScalarAsCtype_(src.ptr(), &value);
  out = cytnx::Scalar(static_cast<CytnxT>(value));
  return true;
}

inline bool load_standard_numpy_scalar(pybind11::handle src, cytnx::Scalar &out) {
  using namespace cytnx;
  // Put the common coefficient types first; each failed check is one pointer comparison.
  return try_numpy_scalar_exact<float, cytnx_float>(src, out)
         || try_numpy_scalar_exact<double, cytnx_double>(src, out)
         || try_numpy_scalar_exact<std::complex<float>, cytnx_complex64>(src, out)
         || try_numpy_scalar_exact<std::complex<double>, cytnx_complex128>(src, out)
         || try_numpy_scalar_exact<bool, cytnx_bool>(src, out)
         || try_numpy_scalar_exact<std::int8_t, cytnx_int16>(src, out)
         || try_numpy_scalar_exact<std::uint8_t, cytnx_uint16>(src, out)
         || try_numpy_scalar_exact<std::int16_t, cytnx_int16>(src, out)
         || try_numpy_scalar_exact<std::uint16_t, cytnx_uint16>(src, out)
         || try_numpy_scalar_exact<std::int32_t, cytnx_int32>(src, out)
         || try_numpy_scalar_exact<std::uint32_t, cytnx_uint32>(src, out)
         || try_numpy_scalar_exact<std::int64_t, cytnx_int64>(src, out)
         || try_numpy_scalar_exact<std::uint64_t, cytnx_uint64>(src, out);
}

load_scalar_arg() should call this before broad PyFloat_Check/PyComplex_Check branches, because under NumPy 2.x np.float64 and np.complex128 are subclasses of Python float and complex. Keep the existing Python bool/int/float/complex handling for native values.

The exact Py_TYPE comparison is the hot path. If NumPy scalar subclasses must also be accepted, add a rare fallback pass using PyObject_TypeCheck(src.ptr(), expected) after the exact/native paths. That preserves subclass support without slowing standard scalar calls.

The deliberate tradeoff is the two pybind11::detail references. They mirror pybind11 3.0.4's own numpy_scalar<T> caster, require no NumPy build-time headers, and are reasonable here because pybind11 is exactly pinned. This dependency should be noted beside the helper and revalidated whenever the pin changes.

GPT-5.6 Sol Max

@yingjerkao
yingjerkao force-pushed the fix/1045-remove-scalar-python-binding branch from 6288186 to 2e96b24 Compare July 21, 2026 09:25

@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: 2e96b24867

ℹ️ 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 pybind/unitensor_py.cpp
Comment on lines 1550 to 1551
.def("__add__",
[](UniTensor &self, const cytnx::cytnx_double &rhs) { return linalg::Add(self, rhs); })

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 Preserve 8-bit numpy UniTensor scalar dtypes

When users replace cytnx.Scalar(np.int8(...))/np.uint8(...) with the underlying numpy scalar for UniTensor arithmetic, this keep-set has no 8-bit numpy overload and py::int_ does not accept numpy integer scalars, so the value falls through to this cytnx_double overload. linalg::Add then promotes Scalar(lc).dtype() with the UniTensor dtype (src/linalg/Add.cpp:360), so cases such as cytnx.UniTensor(cytnx.zeros([1], dtype=Type.Float)) + np.int8(1) now produce a Double UniTensor instead of following the old integer-Scalar path that widened to Int16 and preserved Float output.

Useful? React with 👍 / 👎.

@yingjerkao
yingjerkao merged commit 2d66464 into master Jul 22, 2026
34 checks passed
@yingjerkao
yingjerkao deleted the fix/1045-remove-scalar-python-binding branch July 22, 2026 01: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.

Remove the cytnx.Scalar Python binding (Python should derive type from dtype)

2 participants