Skip to content

Close performance gap between lookup of freshly built and deserialized algorithms #12161

Open

Description

@oleksandr-pavlyk

__call__ operator of every algorithm in cuda.compute executes cuda.compute._cccl_interop.resolve_build_result with two arguments:

   resolve_build_result(build_results: dict,  build_result=None)

If caller set build_result the function short-circuits and returns that, otherwise it does more work, i.e., queries current device to get its ID and CC it suppors and calls build_results.resolve(build_result_cc, device_id).

Profiling of cuda.compute-accelerated cupy.ndarray.sort implementation found calls to resolve_build_result in the hot path.

Due to CuPy's own caching it mainly deserializes cuda.compute algorithms retrieved from its disk cache. Unlike freshly constructed algorithm, which sets self._bound_build_result data member to the actual loaded value, algorithm instances constructed using deserialize method set it to None.

Hence, __call__ methods for these differently constructed instances end up executing different path of resolve_build_result.

This difference is particularly strong for temp-storage-size queries, or for computations with small input sizes.

For example, micro-benchmarking for instance returned by cuda.compute.algorithms.make_merge_sort gives:

Setup / first queries: {"factory_ms": 1609.122467, "serialize_ms": 0.206952, "deserialize_ms": 0.169571, "built_first_query_us": 178.894, "deserialized_first_query_us": 72.083}
Workspace: 10751 bytes; warming complete.
Microseconds/query: P5 P25 P50 P75 P95 (batch averages)
       built: 1.116 1.142 1.164 1.192 1.499 (51 trials)
deserialized: 1.669 1.698 1.731 1.824 2.040 (51 trials)
Median paired difference (deserialized - built): 0.571 us/query
Benchmarking script
# Script invocation
$ python profile_merge_sort_query.py --trials 51 --batch 5000 --case both
{
  "python": "3.15.0",
  "executable": "/home/opavlyk/miniforge/envs/cupy-py315/bin/python",
  "cupy_version": "14.3.0.dev0",
  "cupy_path": "/home/opavlyk/miniforge/envs/cupy-py315/lib/python3.15/site-packages/cupy/__init__.py",
  "cuda_cccl_version": "3.6.0.dev804+gf45a18423",
  "compute_path": "/home/opavlyk/miniforge/envs/cupy-py315/lib/python3.15/site-packages/cuda/compute/__init__.py",
  "options": {
    "size": 2048,
    "dtype": "float32",
    "device": 0,
    "case": "both",
    "trials": 51,
    "batch_size": 5000,
    "warmup": 20,
    "duration": null,
    "output": null
  },
  "comparator": "OpKind.LESS"
}
Setup / first queries: {"factory_ms": 1609.122467, "serialize_ms": 0.206952, "deserialize_ms": 0.169571, "built_first_query_us": 178.894, "deserialized_first_query_us": 72.083}
Workspace: 10751 bytes; warming complete.
Microseconds/query: P5 P25 P50 P75 P95 (batch averages)
       built: 1.116 1.142 1.164 1.192 1.499 (51 trials)
deserialized: 1.669 1.698 1.731 1.824 2.040 (51 trials)
Median paired difference (deserialized - built): 0.571 us/query
"""Measure cuda.compute merge-sort workspace-query host overhead.

Compare a factory-built algorithm with one restored from its serialized blob.
Only algorithm(temp_storage=None, ...) is repeated in the measured loops.
No workspace allocation, sorting execution, or stream synchronization occurs
inside those loops. Each sample is the average time per query in one batch.

Examples (run outside the CuPy checkout, using the installed wheel):
  python profile_merge_sort_query.py --trials 101 --output query-results.json
  python -m profiling.sampling run --mode cpu --native --flamegraph -r 10khz \\
      -o query-built.html profile_merge_sort_query.py --case built --duration 60
  python -m profiling.sampling run --mode cpu --native --flamegraph -r 10khz \\
      -o query-deserialized.html profile_merge_sort_query.py \\
      --case deserialized --duration 60

Uses the installed cuda.compute and CuPy, with no source overlay. The comparator
is the built-in OpKind.LESS; this isolates query overhead without custom-op JIT.
"""

import argparse
import json
import math
from pathlib import Path
import platform
import sys
import time


def positive_int(text):
    value = int(text)
    if value <= 0:
        raise argparse.ArgumentTypeError('must be positive')
    return value


# Keep separate named loops to distinguish cases in sampling flamegraphs.
def query_built_batch(algorithm, arguments, count):
    for _ in range(count):
        size = algorithm(**arguments)
    return size


def query_deserialized_batch(algorithm, arguments, count):
    for _ in range(count):
        size = algorithm(**arguments)
    return size


def main():
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--size', type=positive_int, default=2048)
    parser.add_argument('--dtype', choices=('int32', 'int64', 'float32', 'float64'),
                        default='float32')
    parser.add_argument('--device', type=int, default=0)
    parser.add_argument('--case', choices=('both', 'built', 'deserialized'),
                        default='both')
    parser.add_argument('--trials', type=positive_int, default=101,
                        help='trials per case, unless --duration is supplied')
    parser.add_argument('--batch-size', type=positive_int, default=1000,
                        help='queries per measured trial')
    parser.add_argument('--warmup', type=positive_int, default=20)
    parser.add_argument('--duration', type=float,
                        help='run for this many seconds instead of --trials; '
                             'complete each built/deserialized pair')
    parser.add_argument('--output', type=Path,
                        help='save metadata and raw trials as JSON (new file)')
    args = parser.parse_args()
    if args.duration is not None and (
            not math.isfinite(args.duration) or args.duration <= 0):
        parser.error('--duration must be finite and positive')
    if args.output is not None and args.output.exists():
        parser.error(f'output already exists: {args.output}')

    import numpy as np
    import cupy as cp
    import cuda.cccl
    import cuda.compute as cc

    if cp.cuda.runtime.is_hip:
        parser.error('requires a CUDA build of CuPy')
    metadata = dict(python=platform.python_version(), executable=sys.executable,
                    cupy_version=cp.__version__, cupy_path=cp.__file__,
                    cuda_cccl_version=cuda.cccl.__version__,
                    compute_path=cc.__file__, options={
                        **vars(args), 'output': str(args.output)
                        if args.output is not None else None},
                    comparator='OpKind.LESS')
    print(json.dumps(metadata, indent=2), flush=True)

    with cp.cuda.Device(args.device):
        d_in = cp.ones(args.size, dtype=args.dtype)
        d_out = cp.full_like(d_in, -7)
        stream = cp.cuda.get_current_stream()
        stream.synchronize()
        arguments = dict(temp_storage=None, d_in_keys=d_in, d_out_keys=d_out,
                         d_in_values=None, d_out_values=None,
                         num_items=args.size, op=cc.OpKind.LESS, stream=stream)
        phases = {}
        start = time.perf_counter_ns()
        built = cc.make_merge_sort(d_in_keys=d_in, d_out_keys=d_out,
                                   d_in_values=None, d_out_values=None,
                                   op=cc.OpKind.LESS)
        phases['factory_ms'] = (time.perf_counter_ns() - start) / 1e6
        algorithms = {'built': built}
        if args.case != 'built':
            start = time.perf_counter_ns()
            blob = built.serialize()
            phases['serialize_ms'] = (time.perf_counter_ns() - start) / 1e6
            start = time.perf_counter_ns()
            algorithms['deserialized'] = cc.deserialize(blob)
            phases['deserialize_ms'] = (time.perf_counter_ns() - start) / 1e6
        cases = (['built', 'deserialized'] if args.case == 'both'
                 else [args.case])
        expected_size = None
        for case in cases:
            start = time.perf_counter_ns()
            size = algorithms[case](**arguments)
            phases[case + '_first_query_us'] = (
                time.perf_counter_ns() - start) / 1e3
            if expected_size is None:
                expected_size = size
            if size != expected_size:
                raise RuntimeError('workspace queries disagree')
            for _ in range(args.warmup):
                if algorithms[case](**arguments) != expected_size:
                    raise RuntimeError('workspace size changed during warmup')
        print('Setup / first queries:', json.dumps(phases), flush=True)
        print(f'Workspace: {expected_size} bytes; warming complete.', flush=True)
        loops = {'built': query_built_batch,
                 'deserialized': query_deserialized_batch}
        trials = []
        started = time.perf_counter()
        round_index = 0
        while (round_index < args.trials if args.duration is None else
               round_index == 0 or time.perf_counter() - started < args.duration):
            # Reverse order on alternating rounds to reduce ordering bias.
            order = cases if round_index % 2 == 0 else cases[::-1]
            for case in order:
                start = time.perf_counter_ns()
                size = loops[case](algorithms[case], arguments, args.batch_size)
                elapsed_ns = time.perf_counter_ns() - start
                if size != expected_size:
                    raise RuntimeError('workspace size changed during timing')
                trials.append(dict(case=case, round=round_index + 1,
                                   elapsed_ns=elapsed_ns,
                                   queries=args.batch_size,
                                   us_per_query=elapsed_ns / args.batch_size / 1e3))
            round_index += 1
        elapsed = time.perf_counter() - started
        # Check query-only behavior outside the measured region.
        if not bool(cp.all(d_in == 1)) or not bool(cp.all(d_out == -7)):
            raise RuntimeError('workspace query modified input/output arrays')

    percentiles = [5, 25, 50, 75, 95]
    summary = {}
    print('Microseconds/query: P5 P25 P50 P75 P95 (batch averages)')
    for case in cases:
        values = [t['us_per_query'] for t in trials if t['case'] == case]
        quantiles = np.percentile(values, percentiles).tolist()
        summary[case] = dict(trials=len(values), queries=len(values)*args.batch_size,
                             percentiles_us=dict(zip(map(str, percentiles), quantiles)))
        print(f'{case:>12}: ' + ' '.join(f'{v:.3f}' for v in quantiles)
              + f' ({len(values)} trials)', flush=True)
    if len(cases) == 2:
        paired_delta = []
        for i in range(0, len(trials), 2):
            pair = {t['case']: t['us_per_query'] for t in trials[i:i+2]}
            paired_delta.append(pair['deserialized'] - pair['built'])
        summary['paired_deserialized_minus_built_median_us'] = float(
            np.median(paired_delta))
        print('Median paired difference (deserialized - built): '
              f'{np.median(paired_delta):.3f} us/query')
    if args.output is not None:
        with args.output.open('x') as f:
            json.dump(dict(metadata=metadata, phases=phases,
                           workspace_bytes=expected_size, elapsed_s=elapsed,
                           summary=summary, trials=trials), f, indent=2)
        print(f'Saved {args.output}')


if __name__ == '__main__':
    main()

cuda.compute needs to expose API to bind deserialized algorithm for explicit device.

portable = cuda.compute.deserialize(blob)
executable = portable.bind(device_id=device_id)  # Proposed API.

Activity

  1. moved this from Todo to In Progress in CCCLon Nov 1, 2026
  2. moved this from In Progress to In Review in CCCLon Nov 1, 2026
  3. MaybeWilli commented on Nov 1, 2026

    @MaybeWilli

    I'd like to take a look at this one if that's OK.

  4. moved this from In Review to In Progress in CCCLon Nov 10, 2026
  5. moved this from In Progress to In Review in CCCLon Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

  • Status
    In Review

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions