Repository navigation
Description
__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.Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn Review
Activity
MaybeWilli commentedon Nov 1, 2026
I'd like to take a look at this one if that's OK.