• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daisytuner / docc / 31121514451

06 Aug 2026 04:55PM UTC coverage: 65.036% (+0.02%) from 65.021%
31121514451

Pull #940

github

web-flow
Merge 50b8a37e8 into 7d5b198bd
Pull Request #940: Compile time reporting

32 of 34 new or added lines in 3 files covered. (94.12%)

1 existing line in 1 file now uncovered.

46293 of 71181 relevant lines covered (65.04%)

717.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.43
/python/docc/python/python_program.py
1
import inspect
4✔
2
import json
4✔
3
import shutil
4✔
4
import textwrap
4✔
5
import ast
4✔
6
import os
4✔
7
import getpass
4✔
8
import hashlib
4✔
9
import ml_dtypes
4✔
10
import numpy as np
4✔
11
from typing import Annotated, get_origin, get_args, Any, Optional
4✔
12
import time
4✔
13

14
from docc.sdfg import (
4✔
15
    Scalar,
16
    PrimitiveType,
17
    Pointer,
18
    Structure,
19
    Array,
20
    Type,
21
    Tensor,
22
    StructuredSDFG,
23
    StructuredSDFGBuilder,
24
    DoccMetrics,
25
)
26
from docc.compiler.docc_program import DoccProgram
4✔
27
from docc.compiler.compiled_sdfg import CompiledSDFG
4✔
28
from docc.python.ast_parser import ASTParser
4✔
29
from docc.python.types import element_type_from_sdfg_type
4✔
30

31

32
def _compile_wrapper(self, output_folder=None):
4✔
33
    """Wrapper to allow StructuredSDFG.compile() to return a CompiledSDFG."""
34
    lib_path = self._compile(output_folder)
×
35
    return CompiledSDFG(lib_path, self)
×
36

37

38
# Monkey-patch StructuredSDFG to add compile method
39
StructuredSDFG.compile = _compile_wrapper
4✔
40

41

42
def _map_python_type(dtype):
4✔
43
    """Map Python/numpy types to SDFG types."""
44
    # If it is already a sdfg Type, return it
45
    if isinstance(dtype, Type):
4✔
46
        return dtype
×
47

48
    # Handle Annotated for Arrays
49
    if get_origin(dtype) is Annotated:
4✔
50
        args = get_args(dtype)
×
51
        base_type = args[0]
×
52
        metadata = args[1:]
×
53

54
        if base_type is np.ndarray:
×
55
            # Convention: Annotated[np.ndarray, shape, dtype]
56
            shape = metadata[0]
×
57
            elem_type = Scalar(PrimitiveType.Double)  # Default
×
58

59
            if len(metadata) > 1:
×
60
                possible_dtype = metadata[1]
×
61
                elem_type = _map_python_type(possible_dtype)
×
62

63
            return Pointer(elem_type)
×
64

65
    # Handle numpy.ndarray[Shape, DType]
66
    if get_origin(dtype) is np.ndarray:
4✔
67
        args = get_args(dtype)
×
68
        # args[0] is shape, args[1] is dtype
69
        if len(args) >= 2:
×
70
            elem_type = _map_python_type(args[1])
×
71
            return Pointer(elem_type)
×
72

73
    # Handle a parametrized numpy dtype generic, e.g. numpy.dtype[numpy.float64]
74
    # (produced by npt.NDArray[RealT] -> ndarray[Any, dtype[float64]]).
75
    if get_origin(dtype) is np.dtype:
4✔
76
        inner = get_args(dtype)
×
77
        if inner:
×
78
            return _map_python_type(inner[0])
×
79

80
    # Simple mapping for python types
81
    if dtype is float or dtype is np.float64:
4✔
82
        return Scalar(PrimitiveType.Double)
4✔
83
    elif dtype is np.float32:
4✔
84
        return Scalar(PrimitiveType.Float)
×
85
    elif dtype is bool or dtype is np.bool_:
4✔
86
        return Scalar(PrimitiveType.Bool)
4✔
87
    elif dtype is int or dtype is np.int64:
4✔
88
        return Scalar(PrimitiveType.Int64)
4✔
89
    elif dtype is np.int32:
×
90
        return Scalar(PrimitiveType.Int32)
×
91
    elif dtype is np.int16:
×
92
        return Scalar(PrimitiveType.Int16)
×
93
    elif dtype is np.int8:
×
94
        return Scalar(PrimitiveType.Int8)
×
95
    elif dtype is np.uint64:
×
96
        return Scalar(PrimitiveType.UInt64)
×
97
    elif dtype is np.uint32:
×
98
        return Scalar(PrimitiveType.UInt32)
×
99
    elif dtype is np.uint16:
×
100
        return Scalar(PrimitiveType.UInt16)
×
101
    elif dtype is np.uint8:
×
102
        return Scalar(PrimitiveType.UInt8)
×
103

104
    # Handle Python classes - map to Structure type
105
    if inspect.isclass(dtype):
×
106
        # Use the class name as the structure name
107
        return Pointer(Structure(dtype.__name__))
×
108

109
    return dtype
×
110

111

112
class PythonProgram(DoccProgram):
4✔
113

114
    def __init__(
4✔
115
        self,
116
        func,
117
        target: str = "none",
118
        category: str = "server",
119
        instrumentation_mode: Optional[str] = None,
120
        capture_args: Optional[bool] = None,
121
        remote_tuning: bool = False,
122
    ):
123
        super().__init__(
4✔
124
            name=func.__name__,
125
            target=target,
126
            category=category,
127
            instrumentation_mode=instrumentation_mode,
128
            capture_args=capture_args,
129
            remote_tuning=remote_tuning,
130
        )
131
        self.func = func
4✔
132
        self._last_structure_member_info = {}
4✔
133

134
    def __call__(self, *args: Any) -> Any:
4✔
135
        # JIT compile and run. CompiledSDFG validates the call mode (numpy /
136
        # cupy / torch) and rejects GPU arrays on non-device-resident artifacts.
137
        compiled = self.compile(*args)
4✔
138
        res = compiled(*args)
4✔
139

140
        # Handle return value conversion based on annotation
141
        sig = inspect.signature(self.func)
4✔
142
        ret_annotation = sig.return_annotation
4✔
143

144
        if ret_annotation is not inspect.Signature.empty:
4✔
145
            if get_origin(ret_annotation) is Annotated:
4✔
146
                type_args = get_args(ret_annotation)
×
147
                if len(type_args) >= 1 and type_args[0] is np.ndarray:
×
148
                    shape = None
×
149
                    if len(type_args) >= 2:
×
150
                        shape = type_args[1]
×
151

152
                    if shape is not None:
×
153
                        try:
×
154
                            return np.ctypeslib.as_array(res, shape=shape)
×
155
                        except Exception:
×
156
                            pass
×
157

158
        # Try to infer return shape from metadata
159
        if hasattr(compiled, "get_return_shape"):
4✔
160
            shape = compiled.get_return_shape(*args)
4✔
161
            if shape is not None:
4✔
162
                try:
×
163
                    return np.ctypeslib.as_array(res, shape=shape)
×
164
                except Exception:
×
165
                    pass
×
166

167
        return res
4✔
168

169
    def compile(
4✔
170
        self,
171
        *args: Any,
172
        output_folder: Optional[str] = None,
173
        instrumentation_mode: Optional[str] = None,
174
        capture_args: Optional[bool] = None,
175
        remote_tuning: Optional[bool] = None,
176
    ) -> CompiledSDFG:
177
        original_output_folder = output_folder
4✔
178

179
        metrics = DoccMetrics()
4✔
180
        compile_start_time = time.perf_counter()
4✔
181
        metrics.add_metric("function", self.name, "source")
4✔
182
        metrics.add_frontend_source_info("python")
4✔
183

184
        # Resolve options
185
        instrumentation_mode, capture_args, remote_tuning = (
4✔
186
            self._resolve_compile_options(
187
                instrumentation_mode, capture_args, remote_tuning
188
            )
189
        )
190

191
        # When binary reuse is requested, the build run must persist the
192
        # normalized SDFG (py4.norm.json) so a later run can reload it without
193
        # re-parsing/recompiling. Force the dump if instrumentation/capture
194
        # would not already produce it.
195
        docc_reuse_binaries = os.environ.get("DOCC_REUSE_BINARIES")
4✔
196
        if docc_reuse_binaries and not self.debug_dump:
4✔
197
            self.debug_dump = True
×
198

199
        # 1. Analyze arguments and shapes
200
        arg_types = []
4✔
201
        shape_values = []  # List of unique shape values found
4✔
202
        shape_sources = []  # List of (arg_idx, dim_idx) for each unique shape value
4✔
203

204
        # Mapping from (arg_idx, dim_idx) -> unique_shape_idx
205
        arg_shape_mapping = {}
4✔
206

207
        # First pass: collect scalar integer arguments and their values
208
        sig = inspect.signature(self.func)
4✔
209
        params = list(sig.parameters.items())
4✔
210
        scalar_int_params = {}  # Maps value -> parameter name (first one wins)
4✔
211
        for i, ((name, param), arg) in enumerate(zip(params, args)):
4✔
212
            if isinstance(arg, (int, np.integer)) and not isinstance(
4✔
213
                arg, (bool, np.bool_)
214
            ):
215
                val = int(arg)
4✔
216
                if val not in scalar_int_params:
4✔
217
                    scalar_int_params[val] = name
4✔
218

219
        for i, arg in enumerate(args):
4✔
220
            t = self._infer_type(arg)
4✔
221
            arg_types.append(t)
4✔
222

223
            if isinstance(arg, np.ndarray):
4✔
224
                for dim_idx, dim_val in enumerate(arg.shape):
4✔
225
                    # Check if we've seen this value
226
                    if dim_val in shape_values:
4✔
227
                        # Reuse
228
                        u_idx = shape_values.index(dim_val)
4✔
229
                    else:
230
                        # New
231
                        u_idx = len(shape_values)
4✔
232
                        shape_values.append(dim_val)
4✔
233
                        shape_sources.append((i, dim_idx))
4✔
234

235
                    arg_shape_mapping[(i, dim_idx)] = u_idx
4✔
236

237
        # 2. Signature - include scalar-shape equivalences for correct caching
238
        mapping_sig = sorted(arg_shape_mapping.items())
4✔
239
        type_sig = ", ".join(self._type_to_str(t) for t in arg_types)
4✔
240
        signature = f"{type_sig}|{mapping_sig}"
4✔
241

242
        # In-memory cache key: the structural signature plus the resolved compile
243
        # options, so repeated in-process compiles with different
244
        # instrumentation/arg-capture/remote-tuning do not alias to the first
245
        # built binary (the on-disk hash already accounts for these options).
246
        mem_cache_key = (
4✔
247
            f"{signature}|{capture_args}|{instrumentation_mode}|{remote_tuning}"
248
        )
249

250
        if output_folder is None:
4✔
251
            source_path = inspect.getsourcefile(self.func)
4✔
252
            hash_input = f"{source_path}|{self.name}|{self.target}|{self.category}|{capture_args}|{instrumentation_mode}|{remote_tuning}|{signature}".encode(
4✔
253
                "utf-8"
254
            )
255
            stable_id = hashlib.sha256(hash_input).hexdigest()[:16]
4✔
256
            filename = os.path.basename(inspect.getsourcefile(self.func))
4✔
257

258
            docc_tmp = os.environ.get("DOCC_TMP")
4✔
259
            if docc_tmp:
4✔
260
                output_folder = (
×
261
                    f"{docc_tmp}/{filename}-{self.name}-{self.target}-{stable_id}"
262
                )
263
            else:
264
                user = os.getenv("USER")
4✔
265
                if not user:
4✔
266
                    user = getpass.getuser()
4✔
267
                output_folder = f"/tmp/{user}/DOCC/{self.name}-{stable_id}"
4✔
268

269
        if original_output_folder is None and mem_cache_key in self.cache:
4✔
270
            return self.cache[mem_cache_key]
4✔
271

272
        # 3. Reuse a previously built binary if requested and available.
273
        # Structure arguments need per-member layout info that is only produced
274
        # while parsing the kernel, so reuse is limited to plain array/scalar
275
        # kernels; anything else falls through to a full rebuild.
276
        has_struct_args = any(
4✔
277
            isinstance(t, Pointer)
278
            and t.has_pointee_type()
279
            and isinstance(t.pointee_type, Structure)
280
            for t in arg_types
281
        )
282
        if docc_reuse_binaries and not has_struct_args:
4✔
283
            reused = self._try_reuse_binary(output_folder, shape_sources)
×
284
            if reused is not None:
×
285
                if original_output_folder is None:
×
286
                    self.cache[mem_cache_key] = reused
×
287

NEW
288
                metrics.capture_env_vars()
×
NEW
289
                metrics.append_to(output_folder)
×
UNCOV
290
                return reused
×
291

292
        # 4. Build SDFG
293
        if os.path.exists(output_folder):
4✔
294
            # Multiple python processes running the same code?
295
            shutil.rmtree(output_folder)
4✔
296
        sdfg, out_args, out_shapes, out_strides = self._build_sdfg(
4✔
297
            arg_types, args, arg_shape_mapping, shape_values
298
        )
299

300
        lib_path = self.sdfg_pipe(
4✔
301
            sdfg,
302
            output_folder,
303
            instrumentation_mode,
304
            capture_args,
305
            remote_tuning,
306
            metrics=metrics,
307
        )
308

309
        # Persist the return-value layout so a later DOCC_REUSE_BINARIES run can
310
        # rebuild the CompiledSDFG without re-parsing the kernel.
311
        if output_folder:
4✔
312
            self._persist_return_layout(output_folder, sdfg, out_shapes, out_strides)
4✔
313

314
        # 5. Create CompiledSDFG
315
        compiled = CompiledSDFG(
4✔
316
            lib_path,
317
            sdfg,
318
            shape_sources,
319
            self._last_structure_member_info,
320
            out_args,
321
            out_shapes,
322
            out_strides,
323
            device_resident=self._device_resident,
324
            device_backend=self._device_backend,
325
            target=self.target,
326
        )
327

328
        # Cache if using default output folder
329
        if original_output_folder is None:
4✔
330
            self.cache[mem_cache_key] = compiled
4✔
331

332
        compile_time_ms = round((time.perf_counter() - compile_start_time) * 1000)
4✔
333
        metrics.add_metric("compile_time_ms", compile_time_ms, "compile")
4✔
334
        metrics.capture_env_vars()
4✔
335
        metrics.append_to(output_folder)
4✔
336

337
        return compiled
4✔
338

339
    def _persist_return_layout(
4✔
340
        self, output_folder: str, sdfg: StructuredSDFG, out_shapes, out_strides
341
    ) -> None:
342
        """Stamp the return-value layout into the persisted SDFG metadata.
343

344
        The return shapes/strides are discovered while parsing the kernel and
345
        are not otherwise recoverable from the SDFG structure. Persisting them
346
        (into the same ``py4.norm.json`` the reuse path loads) lets a later
347
        ``DOCC_REUSE_BINARIES`` run reconstruct the CompiledSDFG without
348
        re-parsing/recompiling.
349
        """
350
        json_path = os.path.join(output_folder, f"{sdfg.name}.py4.norm.json")
4✔
351
        if not os.path.exists(json_path):
4✔
352
            return
4✔
353
        try:
×
354
            with open(json_path) as f:
×
355
                data = json.load(f)
×
356
            metadata = data.setdefault("metadata", {})
×
357
            metadata["output_shapes"] = json.dumps(out_shapes)
×
358
            metadata["output_strides"] = json.dumps(out_strides)
×
359
            with open(json_path, "w") as f:
×
360
                json.dump(data, f)
×
361
        except (OSError, ValueError):
×
362
            pass
×
363

364
    def _try_reuse_binary(
4✔
365
        self, output_folder: Optional[str], shape_sources
366
    ) -> Optional[CompiledSDFG]:
367
        """Reload a cached ``.so`` + normalized SDFG instead of recompiling.
368

369
        Mirrors the strictness of the pytorch/mlir binary-reuse path: when the
370
        cache directory does not exist yet this returns ``None`` so the caller
371
        performs a first build (no error); but when the directory *does* exist
372
        and a required artifact is missing, it raises ``ValueError`` so a broken
373
        or stale cache surfaces loudly instead of silently recompiling. The
374
        calling convention (device residency) and return-value layout are
375
        restored from the persisted SDFG metadata so arguments are marshalled
376
        exactly as they were at build time.
377
        """
378
        if not output_folder:
×
379
            return None
×
380

381
        # Cache directory absent -> first build. Let the caller build it; this
382
        # is the one case the mlir/pytorch frontends also treat as non-fatal.
383
        if not os.path.exists(output_folder):
×
384
            return None
×
385

386
        sdfg_name = f"{self.name}_sdfg"
×
387
        lib_path = os.path.join(output_folder, f"lib{sdfg_name}.so")
×
388
        json_path = os.path.join(output_folder, f"{sdfg_name}.py4.norm.json")
×
389
        if not os.path.exists(lib_path):
×
390
            raise ValueError(f"Tried reusing binary '{lib_path}' but does not exist")
×
391
        if not os.path.exists(json_path):
×
392
            raise ValueError(f"Tried loading SDFG '{json_path}' but does not exist")
×
393

394
        sdfg = StructuredSDFG.from_file(json_path)
×
395

396
        # Return arguments are recoverable directly from the SDFG signature.
397
        out_args = [name for name in sdfg.arguments if name.startswith("_docc_ret_")]
×
398

399
        # Return-value layout was persisted into the SDFG metadata at build time.
400
        out_shapes = {}
×
401
        out_strides = {}
×
402
        shapes_meta = sdfg.metadata("output_shapes")
×
403
        strides_meta = sdfg.metadata("output_strides")
×
404
        if shapes_meta:
×
405
            try:
×
406
                out_shapes = json.loads(shapes_meta)
×
407
            except ValueError:
×
408
                out_shapes = {}
×
409
        if strides_meta:
×
410
            try:
×
411
                out_strides = json.loads(strides_meta)
×
412
            except ValueError:
×
413
                out_strides = {}
×
414

415
        # Restore the device-residency calling convention chosen at compile time;
416
        # otherwise a device-resident binary would be fed host pointers.
417
        self._device_resident = sdfg.metadata("device_resident") == "1"
×
418
        backend = sdfg.metadata("device_backend")
×
419
        self._device_backend = backend or None
×
420

421
        return CompiledSDFG(
×
422
            lib_path,
423
            sdfg,
424
            shape_sources,
425
            {},
426
            out_args,
427
            out_shapes,
428
            out_strides,
429
            device_resident=self._device_resident,
430
            device_backend=self._device_backend,
431
            target=self.target,
432
        )
433

434
    def to_sdfg(self, *args: Any) -> StructuredSDFG:
4✔
435
        arg_types = [self._infer_type(arg) for arg in args]
×
436

437
        # Build shape mapping
438
        shape_values = []
×
439
        shape_sources = []
×
440
        arg_shape_mapping = {}
×
441

442
        sig = inspect.signature(self.func)
×
443
        params = list(sig.parameters.items())
×
444
        scalar_int_params = {}
×
445
        for i, ((name, param), arg) in enumerate(zip(params, args)):
×
446
            if isinstance(arg, (int, np.integer)) and not isinstance(
×
447
                arg, (bool, np.bool_)
448
            ):
449
                val = int(arg)
×
450
                if val not in scalar_int_params:
×
451
                    scalar_int_params[val] = name
×
452

453
        for i, arg in enumerate(args):
×
454
            if isinstance(arg, np.ndarray):
×
455
                for dim_idx, dim_val in enumerate(arg.shape):
×
456
                    if dim_val in shape_values:
×
457
                        u_idx = shape_values.index(dim_val)
×
458
                    else:
459
                        u_idx = len(shape_values)
×
460
                        shape_values.append(dim_val)
×
461
                        shape_sources.append((i, dim_idx))
×
462
                    arg_shape_mapping[(i, dim_idx)] = u_idx
×
463

464
        sdfg, _, _, _ = self._build_sdfg(
×
465
            arg_types, args, arg_shape_mapping, shape_values
466
        )
467
        return sdfg
×
468

469
    def _convert_inputs(self, args: tuple) -> tuple:
4✔
470
        return args
×
471

472
    def _convert_outputs(self, result: Any, original_args: tuple) -> Any:
4✔
473
        return result
×
474

475
    def _get_signature(self, arg_types):
4✔
476
        return ", ".join(self._type_to_str(t) for t in arg_types)
×
477

478
    def _type_to_str(self, t):
4✔
479
        if isinstance(t, Scalar):
4✔
480
            return f"Scalar({t.primitive_type})"
4✔
481
        elif isinstance(t, Array):
4✔
482
            return f"Array({self._type_to_str(t.element_type)}, {t.num_elements})"
×
483
        elif isinstance(t, Pointer):
4✔
484
            return f"Pointer({self._type_to_str(t.pointee_type)})"
4✔
485
        elif isinstance(t, Structure):
4✔
486
            return f"Structure({t.name})"
4✔
487
        return str(t)
×
488

489
    def _infer_type(self, arg):
4✔
490
        if isinstance(arg, (float, np.float64)):
4✔
491
            return Scalar(PrimitiveType.Double)
4✔
492
        elif isinstance(arg, np.float32):
4✔
493
            return Scalar(PrimitiveType.Float)
4✔
494
        elif isinstance(arg, (bool, np.bool_)):
4✔
495
            return Scalar(PrimitiveType.Bool)
4✔
496
        elif isinstance(arg, (int, np.int64)):
4✔
497
            return Scalar(PrimitiveType.Int64)
4✔
498
        elif isinstance(arg, np.int32):
4✔
499
            return Scalar(PrimitiveType.Int32)
4✔
500
        elif isinstance(arg, np.int16):
4✔
501
            return Scalar(PrimitiveType.Int16)
×
502
        elif isinstance(arg, np.int8):
4✔
503
            return Scalar(PrimitiveType.Int8)
×
504
        elif isinstance(arg, np.uint64):
4✔
505
            return Scalar(PrimitiveType.UInt64)
×
506
        elif isinstance(arg, np.uint32):
4✔
507
            return Scalar(PrimitiveType.UInt32)
×
508
        elif isinstance(arg, np.uint16):
4✔
509
            return Scalar(PrimitiveType.UInt16)
×
510
        elif isinstance(arg, np.uint8):
4✔
511
            return Scalar(PrimitiveType.UInt8)
×
512
        elif isinstance(arg, np.ndarray):
4✔
513
            # Map dtype
514
            if arg.dtype == np.float64:
4✔
515
                elem_type = Scalar(PrimitiveType.Double)
4✔
516
            elif arg.dtype == np.float32:
4✔
517
                elem_type = Scalar(PrimitiveType.Float)
4✔
518
            elif arg.dtype == np.float16:
4✔
519
                elem_type = Scalar(PrimitiveType.Half)
×
520
            elif arg.dtype == ml_dtypes.bfloat16:
4✔
521
                elem_type = Scalar(PrimitiveType.BFloat)
4✔
522
            elif arg.dtype == np.bool_:
4✔
523
                elem_type = Scalar(PrimitiveType.Bool)
4✔
524
            elif arg.dtype == np.int64:
4✔
525
                elem_type = Scalar(PrimitiveType.Int64)
4✔
526
            elif arg.dtype == np.int32:
4✔
527
                elem_type = Scalar(PrimitiveType.Int32)
4✔
528
            elif arg.dtype == np.int16:
×
529
                elem_type = Scalar(PrimitiveType.Int16)
×
530
            elif arg.dtype == np.int8:
×
531
                elem_type = Scalar(PrimitiveType.Int8)
×
532
            elif arg.dtype == np.uint64:
×
533
                elem_type = Scalar(PrimitiveType.UInt64)
×
534
            elif arg.dtype == np.uint32:
×
535
                elem_type = Scalar(PrimitiveType.UInt32)
×
536
            elif arg.dtype == np.uint16:
×
537
                elem_type = Scalar(PrimitiveType.UInt16)
×
538
            elif arg.dtype == np.uint8:
×
539
                elem_type = Scalar(PrimitiveType.UInt8)
×
540
            else:
541
                raise ValueError(f"Unsupported numpy dtype: {arg.dtype}")
×
542

543
            return Pointer(elem_type)
4✔
544
        elif isinstance(arg, str):
4✔
545
            # Explicitly reject strings - they are not supported
546
            raise ValueError(f"Unsupported argument type: {type(arg)}")
4✔
547
        else:
548
            # Check if it's a class instance
549
            if hasattr(arg, "__class__") and not isinstance(arg, type):
4✔
550
                # It's an instance of a class, return pointer to Structure
551
                return Pointer(Structure(arg.__class__.__name__))
4✔
552
            raise ValueError(f"Unsupported argument type: {type(arg)}")
×
553

554
    def _build_sdfg(
4✔
555
        self,
556
        arg_types,
557
        args,
558
        arg_shape_mapping,
559
        shape_values,
560
    ):
561
        sig = inspect.signature(self.func)
4✔
562

563
        # Handle return type - always void for SDFG, output args used for returns
564
        return_type = Scalar(PrimitiveType.Void)
4✔
565
        infer_return_type = True
4✔
566

567
        # Parse return annotation to determine output arguments if possible
568
        explicit_returns = []
4✔
569
        if sig.return_annotation is not inspect.Signature.empty:
4✔
570
            infer_return_type = False
4✔
571

572
            # Helper to normalize annotation to list of types
573
            def normalize_annotation(ann):
4✔
574
                # Handle Tuple[type, ...]
575
                origin = get_origin(ann)
4✔
576
                if origin is tuple:
4✔
577
                    type_args = get_args(ann)
×
578
                    # Tuple[()] or Tuple w/o args
579
                    if not type_args:
×
580
                        return []
×
581
                    # Tuple[int, float]
582
                    if len(type_args) > 0 and type_args[-1] is not Ellipsis:
×
583
                        return [_map_python_type(t) for t in type_args]
×
584
                    # Tuple[int, ...] - not supported for fixed number of returns yet?
585
                    # For now assume fixed tuple
586
                    return [_map_python_type(t) for t in type_args]
×
587
                else:
588
                    return [_map_python_type(ann)]
4✔
589

590
            explicit_returns = normalize_annotation(sig.return_annotation)
4✔
591
            for rt in explicit_returns:
4✔
592
                if not isinstance(rt, Type):
4✔
593
                    # Fallback if map failed (e.g. invalid annotation)
594
                    infer_return_type = True
×
595
                    explicit_returns = []
×
596
                    break
×
597

598
        builder = StructuredSDFGBuilder(f"{self.name}_sdfg", return_type)
4✔
599

600
        # Add pre-defined return arguments if we know them
601
        if not infer_return_type:
4✔
602
            for i, dtype in enumerate(explicit_returns):
4✔
603
                # Scalar -> Pointer(Scalar)
604
                # Array -> Already Pointer(Scalar). Keep it.
605
                arg_type = dtype
4✔
606
                if isinstance(dtype, Scalar):
4✔
607
                    arg_type = Pointer(dtype)
4✔
608

609
                builder.add_container(f"_docc_ret_{i}", arg_type, is_argument=True)
4✔
610

611
        # Register structure types for any class arguments
612
        # Also track member name to index mapping for each structure
613
        structures_to_register = {}
4✔
614
        structure_member_info = {}  # Maps struct_name -> {member_name: (index, type)}
4✔
615
        for i, (arg, dtype) in enumerate(zip(args, arg_types)):
4✔
616
            if isinstance(dtype, Pointer) and dtype.has_pointee_type():
4✔
617
                pointee = dtype.pointee_type
4✔
618
                if isinstance(pointee, Structure):
4✔
619
                    struct_name = pointee.name
4✔
620
                    if struct_name not in structures_to_register:
4✔
621
                        # Get class from arg to introspect members
622
                        if hasattr(arg, "__dict__"):
4✔
623
                            # Use __dict__ to get only instance attributes
624
                            # Sort by name to ensure consistent ordering
625
                            # Note: This alphabetical ordering is used to define the
626
                            # structure layout and must match the order expected by
627
                            # the backend code generation
628
                            member_types = []
4✔
629
                            member_names = []
4✔
630
                            member_shapes = []
4✔
631
                            for attr_name, attr_value in sorted(arg.__dict__.items()):
4✔
632
                                if not attr_name.startswith("_"):
4✔
633
                                    # Infer member type from instance attribute
634
                                    # Check bool before int since bool is subclass of int
635
                                    member_type = None
4✔
636
                                    member_shape = None
4✔
637
                                    if isinstance(attr_value, bool):
4✔
638
                                        member_type = Scalar(PrimitiveType.Bool)
×
639
                                    elif isinstance(attr_value, (int, np.int64)):
4✔
640
                                        member_type = Scalar(PrimitiveType.Int64)
4✔
641
                                    elif isinstance(attr_value, (float, np.float64)):
4✔
642
                                        member_type = Scalar(PrimitiveType.Double)
4✔
643
                                    elif isinstance(attr_value, np.int32):
4✔
644
                                        member_type = Scalar(PrimitiveType.Int32)
×
645
                                    elif isinstance(attr_value, np.float32):
4✔
646
                                        member_type = Scalar(PrimitiveType.Float)
×
647
                                    elif isinstance(attr_value, np.ndarray):
4✔
648
                                        # Array member: stored as a pointer field
649
                                        # (struct-of-arrays). Record the concrete
650
                                        # shape so attribute access can build a
651
                                        # tensor view over the member pointer.
652
                                        member_type = self._infer_type(attr_value)
4✔
653
                                        member_shape = [
4✔
654
                                            str(int(s)) for s in attr_value.shape
655
                                        ]
656
                                    # TODO: Consider using np.integer and np.floating abstract types
657
                                    # for more comprehensive numpy type coverage
658
                                    # TODO: Add support for nested structures
659

660
                                    if member_type is not None:
4✔
661
                                        member_types.append(member_type)
4✔
662
                                        member_names.append(attr_name)
4✔
663
                                        member_shapes.append(member_shape)
4✔
664

665
                            if member_types:
4✔
666
                                structures_to_register[struct_name] = member_types
4✔
667
                                # Build member name to (index, type, shape) mapping.
668
                                # shape is None for scalar members and a list of
669
                                # dimension-size strings for array members.
670
                                structure_member_info[struct_name] = {
4✔
671
                                    name: (idx, mtype, shape)
672
                                    for idx, (name, mtype, shape) in enumerate(
673
                                        zip(member_names, member_types, member_shapes)
674
                                    )
675
                                }
676

677
        # Store structure_member_info for later use in CompiledSDFG
678
        self._last_structure_member_info = structure_member_info
4✔
679

680
        # Register all discovered structures with the builder
681
        for struct_name, member_types in structures_to_register.items():
4✔
682
            builder.add_structure(struct_name, member_types)
4✔
683

684
        # Register arguments
685
        params = list(sig.parameters.items())
4✔
686
        if len(params) != len(arg_types):
4✔
687
            raise ValueError(
×
688
                f"Argument count mismatch: expected {len(params)}, got {len(arg_types)}"
689
            )
690

691
        # Add regular arguments
692
        tensor_table = {}
4✔
693
        for i, ((name, param), dtype, arg) in enumerate(zip(params, arg_types, args)):
4✔
694
            builder.add_container(name, dtype, is_argument=True)
4✔
695

696
            # Store layout information for arrays
697
            if isinstance(arg, np.ndarray):
4✔
698
                element_type = element_type_from_sdfg_type(dtype)
4✔
699

700
                shapes = []
4✔
701
                for dim_idx in range(arg.ndim):
4✔
702
                    dim_val = arg.shape[dim_idx]
4✔
703
                    if dim_val == 1:
4✔
704
                        # Always use literal "1" for size-1 dimensions to enable
705
                        # proper broadcasting detection
706
                        shapes.append("1")
4✔
707
                    else:
708
                        u_idx = arg_shape_mapping[(i, dim_idx)]
4✔
709
                        shapes.append(f"_s{u_idx}")
4✔
710

711
                strides = []
4✔
712
                if arg.flags["C_CONTIGUOUS"]:
4✔
713
                    # Row-major: stride[i] = product of shapes[i+1:]
714
                    for dim_idx in range(arg.ndim):
4✔
715
                        if dim_idx == arg.ndim - 1:
4✔
716
                            strides.append("1")
4✔
717
                        else:
718
                            suffix_shapes = shapes[dim_idx + 1 :]
4✔
719
                            if len(suffix_shapes) == 1:
4✔
720
                                strides.append(suffix_shapes[0])
4✔
721
                            else:
722
                                strides.append("(" + " * ".join(suffix_shapes) + ")")
4✔
723
                elif arg.flags["F_CONTIGUOUS"]:
4✔
724
                    # Column-major: stride[i] = product of shapes[:i]
725
                    for dim_idx in range(arg.ndim):
4✔
726
                        if dim_idx == 0:
4✔
727
                            strides.append("1")
4✔
728
                        else:
729
                            prefix_shapes = shapes[:dim_idx]
4✔
730
                            if len(prefix_shapes) == 1:
4✔
731
                                strides.append(prefix_shapes[0])
4✔
732
                            else:
733
                                strides.append("(" + " * ".join(prefix_shapes) + ")")
4✔
734
                else:
735
                    # Non-contiguous: use actual stride values
736
                    for dim_idx in range(arg.ndim):
4✔
737
                        stride_val = arg.strides[dim_idx] // arg.itemsize
4✔
738
                        strides.append(f"{stride_val}")
4✔
739

740
                offset = "0"
4✔
741
                tensor_table[name] = Tensor(element_type, shapes, strides, offset)
4✔
742

743
            elif isinstance(arg, np.generic):
4✔
744
                # NumPy scalar types (np.float64, np.int32, etc.) should be treated
745
                # as 0-d arrays for type promotion purposes - they trigger full
746
                # promotion, unlike Python literals which adapt to the array dtype
747
                element_type = element_type_from_sdfg_type(dtype)
4✔
748
                tensor_table[name] = Tensor(element_type, [], [], "0")
4✔
749

750
        # Add unified shape arguments only for shapes without scalar equivalents
751
        # and skip size-1 dimensions (they use literal "1" instead)
752
        for i in range(len(shape_values)):
4✔
753
            if shape_values[i] != 1:
4✔
754
                builder.add_container(
4✔
755
                    f"_s{i}", Scalar(PrimitiveType.Int64), is_argument=True
756
                )
757
                builder.add_assumption_lb(f"_s{i}", "1")  # Shapes must be positive
4✔
758
                builder.add_assumption_const(f"_s{i}", True)  # Shapes are constant
4✔
759

760
        # Create symbol table for parser
761
        container_table = {}
4✔
762
        for i, ((name, param), dtype, arg) in enumerate(zip(params, arg_types, args)):
4✔
763
            container_table[name] = dtype
4✔
764

765
        for i in range(len(shape_values)):
4✔
766
            if shape_values[i] != 1:
4✔
767
                container_table[f"_s{i}"] = Scalar(PrimitiveType.Int64)
4✔
768

769
        # Parse AST
770
        source_lines, start_line = inspect.getsourcelines(self.func)
4✔
771
        source = textwrap.dedent("".join(source_lines))
4✔
772
        tree = ast.parse(source)
4✔
773
        ast.increment_lineno(tree, start_line - 1)
4✔
774
        func_def = tree.body[0]
4✔
775

776
        filename = inspect.getsourcefile(self.func)
4✔
777
        function_name = self.func.__name__
4✔
778

779
        # Combine globals with closure variables (closure takes precedence)
780
        combined_globals = dict(self.func.__globals__)
4✔
781
        if self.func.__closure__ is not None and self.func.__code__.co_freevars:
4✔
782
            for name, cell in zip(
4✔
783
                self.func.__code__.co_freevars, self.func.__closure__
784
            ):
785
                combined_globals[name] = cell.cell_contents
4✔
786

787
        parser = ASTParser(
4✔
788
            builder,
789
            tensor_table,
790
            container_table,
791
            filename,
792
            function_name,
793
            infer_return_type=infer_return_type,
794
            globals_dict=combined_globals,
795
            structure_member_info=structure_member_info,
796
        )
797
        for node in func_def.body:
4✔
798
            parser.visit(node)
4✔
799

800
        # Emit hoisted allocations at function entry
801
        parser.memory_handler.emit_allocations()
4✔
802

803
        sdfg = builder.move()
4✔
804
        # Mark return arguments metadata
805
        out_args = []
4✔
806
        for name in sdfg.arguments:
4✔
807
            if name.startswith("_docc_ret_"):
4✔
808
                out_args.append(name)
4✔
809

810
        return (
4✔
811
            sdfg,
812
            out_args,
813
            parser.captured_return_shapes,
814
            parser.captured_return_strides,
815
        )
816

817

818
def native(
4✔
819
    func=None,
820
    *,
821
    target="none",
822
    category="server",
823
    instrumentation_mode=None,
824
    capture_args=None,
825
    remote_tuning=False,
826
):
827
    """Decorator to create a PythonProgram from a Python function.
828

829
    Example:
830
        @native
831
        def my_function(x: np.ndarray) -> np.ndarray:
832
            return x * 2
833

834
        result = my_function(np.array([1.0, 2.0, 3.0]))
835
    """
836
    if func is None:
4✔
837
        return lambda f: PythonProgram(
4✔
838
            f,
839
            target=target,
840
            category=category,
841
            instrumentation_mode=instrumentation_mode,
842
            capture_args=capture_args,
843
            remote_tuning=remote_tuning,
844
        )
845
    return PythonProgram(
4✔
846
        func,
847
        target=target,
848
        category=category,
849
        instrumentation_mode=instrumentation_mode,
850
        capture_args=capture_args,
851
        remote_tuning=remote_tuning,
852
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc