diff --git a/cuda_core/cuda/core/_linker.pyx b/cuda_core/cuda/core/_linker.pyx index 0687632c3bb..6cc2dcfb1f8 100644 --- a/cuda_core/cuda/core/_linker.pyx +++ b/cuda_core/cuda/core/_linker.pyx @@ -310,6 +310,11 @@ class LinkerOptions: def __post_init__(self) -> None: _lazy_init() + # `name` is annotated `str | None`, so None must fall back to the + # documented default instead of raising AttributeError from + # `.encode()`. Mirrors the same fix for ProgramOptions (#2517). + if self.name is None: + self.name = "" self._name = self.name.encode() def _prepare_nvjitlink_options(self, as_bytes: bool = False) -> list[bytes] | list[str]: @@ -321,7 +326,11 @@ class LinkerOptions: options.append("-arch=sm_" + "".join(f"{i}" for i in Device().compute_capability)) if self.max_register_count is not None: options.append(f"-maxrregcount={self.max_register_count}") - if self.time is not None: + # `-time` is a valueless switch, so it must be gated on truthiness + # like every other valueless flag here (verbose, -lto, -ptx, -g, + # -lineinfo, -no-cache). `is not None` is only right for the flags + # that emit an explicit value, e.g. `-ftz=true|false` below. + if self.time: options.append("-time") if self.verbose: options.append("-verbose") @@ -343,19 +352,24 @@ class LinkerOptions: options.append(f"-prec-sqrt={'true' if self.prec_sqrt else 'false'}") if self.fma is not None: options.append(f"-fma={'true' if self.fma else 'false'}") + # Accept any sequence, not just `list`: both fields are annotated (and + # documented) `str | tuple[str] | list[str]`, and a tuple used to match + # neither branch, so it emitted no option and raised nothing. + # `ptxas_options` below already gets this right. if self.kernels_used is not None: if isinstance(self.kernels_used, str): options.append(f"-kernels-used={self.kernels_used}") - elif isinstance(self.kernels_used, list): + elif is_sequence(self.kernels_used): for kernel in self.kernels_used: options.append(f"-kernels-used={kernel}") if self.variables_used is not None: if isinstance(self.variables_used, str): options.append(f"-variables-used={self.variables_used}") - elif isinstance(self.variables_used, list): + elif is_sequence(self.variables_used): for variable in self.variables_used: options.append(f"-variables-used={variable}") - if self.optimize_unused_variables is not None: + # Valueless switch: see the `-time` note above. + if self.optimize_unused_variables: options.append("-optimize-unused-variables") if self.ptxas_options is not None: if isinstance(self.ptxas_options, str): @@ -398,7 +412,9 @@ class LinkerOptions: if self.max_register_count is not None: formatted_options.append(self.max_register_count) option_keys.append(_driver.CUjit_option.CU_JIT_MAX_REGISTERS) - if self.time is not None: + # Only an option the caller actually turned ON is unsupported; + # `time=False` asks for nothing and must not be rejected. + if self.time: raise ValueError("time option is not supported by the driver API") if self.verbose: formatted_options.append(1) diff --git a/cuda_core/docs/source/release/1.2.0-notes.rst b/cuda_core/docs/source/release/1.2.0-notes.rst index 120d2c2a253..7565147f8db 100644 --- a/cuda_core/docs/source/release/1.2.0-notes.rst +++ b/cuda_core/docs/source/release/1.2.0-notes.rst @@ -73,6 +73,16 @@ Fixes and enhancements Windows, both ``ctypes.CFUNCTYPE`` and ``ctypes.WINFUNCTYPE`` are accepted. (`#2439 `__) +- Four :class:`LinkerOptions` fields now behave as documented. + ``time=False`` and ``optimize_unused_variables=False`` no longer *enable* + those flags -- both are valueless nvJitLink switches that were gated on + ``is not None``, so explicitly disabling them turned them on (and, on the + driver backend, ``time=False`` raised "not supported"). ``kernels_used`` and + ``variables_used`` now accept the documented ``tuple`` form, which + previously matched no branch and emitted nothing at all. ``name=None`` now + falls back to the documented default instead of raising ``AttributeError``, + matching :class:`ProgramOptions`. + Deprecation Notices ------------------- diff --git a/cuda_core/tests/test_linker.py b/cuda_core/tests/test_linker.py index 4f4433a1a1a..8363491bd71 100644 --- a/cuda_core/tests/test_linker.py +++ b/cuda_core/tests/test_linker.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 import inspect +import warnings import pytest @@ -436,6 +437,66 @@ def test_prepare_driver_options_unsupported_raises(driver_binding, kwargs, match opts._prepare_driver_options() +@pytest.mark.agent_authored(model="claude-opus-5") +@pytest.mark.parametrize( + ("field", "flag"), + [("time", "-time"), ("optimize_unused_variables", "-optimize-unused-variables")], +) +def test_valueless_flags_are_gated_on_truthiness(field, flag): + """A valueless nvJitLink switch must not be emitted for ``False``. + + Both were gated on ``is not None``, which is only correct for the flags + that emit an explicit value (``-ftz=true|false`` and friends). So + ``time=False`` turned timing *on*, and ``optimize_unused_variables=False`` + turned the optimization *on* -- silently changing the linked binary by + dropping device variables the caller asked to keep. + """ + assert flag in LinkerOptions(arch=ARCH, **{field: True})._prepare_nvjitlink_options() + assert flag not in LinkerOptions(arch=ARCH, **{field: False})._prepare_nvjitlink_options() + assert flag not in LinkerOptions(arch=ARCH)._prepare_nvjitlink_options() + + +@pytest.mark.agent_authored(model="claude-opus-5") +@pytest.mark.parametrize( + ("field", "flag"), + [("kernels_used", "-kernels-used"), ("variables_used", "-variables-used")], +) +@pytest.mark.parametrize("factory", [list, tuple], ids=["list", "tuple"]) +def test_sequence_options_accept_tuples(field, flag, factory): + """``str | tuple[str] | list[str]`` must all reach the linker. + + The dispatch tested ``isinstance(..., list)``, so the documented tuple form + matched neither branch: no option was emitted and nothing was raised, and + the link silently kept every kernel/variable the caller meant to filter. + """ + emitted = LinkerOptions(arch=ARCH, **{field: factory(("A", "B"))})._prepare_nvjitlink_options() + assert f"{flag}=A" in emitted + assert f"{flag}=B" in emitted + + +@pytest.mark.agent_authored(model="claude-opus-5") +def test_linker_options_accepts_name_none(): + """``name`` is annotated ``str | None``; ``None`` must fall back to the + documented default instead of raising ``AttributeError`` from ``.encode()``. + Same fix as ProgramOptions received in #2517.""" + assert LinkerOptions(arch=ARCH, name=None).name == "" + + +@pytest.mark.agent_authored(model="claude-opus-5") +@pytest.mark.parametrize("field", ["time", "optimize_unused_variables"]) +def test_prepare_driver_options_ignores_disabled_flags(driver_binding, field): + """An option the caller turned OFF is not an option the driver must support. + + ``time=False`` raised "time option is not supported by the driver API" and + ``optimize_unused_variables=False`` emitted a DeprecationWarning, both for + a flag that would never have been sent. + """ + opts = LinkerOptions(arch="sm_80", **{field: False}) + with warnings.catch_warnings(): + warnings.simplefilter("error") + opts._prepare_driver_options() + + def test_linker_empty_object_codes_raises(): """Linker with no ObjectCode raises ValueError.""" with pytest.raises(ValueError, match="At least one ObjectCode object must be provided"):