Adding a New Operator
This page walks through adding a new operator to CV-CUDA end-to-end: generating the scaffold, implementing the kernel, writing tests, adding benchmarks, and updating docs.
The /make-op skill
This page is the narrative how-to. For an assisted, gated workflow, use the make-op skill
family (defined once under .agents/skills/make-op*/SKILL.md; .claude/skills symlinks to it
for Claude Code), whose checklist and definition-of-done live in
.agents/guidance/MAKE_OP_GUIDELINES.md and are enforced by the deterministic checker
tools/make_op.py. Two modes:
Full end-to-end —
/make-op <Name>: propose the operator’s spec (semantics + a cited reference oracle such as a TorchVision/OpenCV function, plus the support matrix) and get user approval; scaffold; implement; then pass the done-gate (tools/make_op.py <Name> --phase done --run), a deterministic regression checklist that reuses/review-op(all domains) and/optimize-oppreflight and additionally requires an independent CPU gold reference, bit-exact coverage across the declared support matrix, required equivalent-layout parity, complement negatives, the operator in the release notes, and tests that actually run and pass. It then hands off to/optimize-opfor performance.Scaffold-only —
/make-op-scaffold <Name> [--bare]: produce a wired, building skeleton and stop, delegating the implementation to a human or another AI.
The steps below are what those skills automate; follow them directly for a manual workflow.
Step 1: Generate the Scaffold
The mkop.sh script generates no-op stubs for every file a new operator
needs and wires them into the build system. Run it from the repository root:
tools/mkop/mkop.sh <OperatorName>
The first letter of OperatorName is capitalized automatically, so clahe
and Clahe both produce Clahe as the canonical name.
Generated files
File |
Purpose |
|---|---|
|
Public C API header |
|
Public C++ header |
|
C API implementation (dispatches to private impl) |
|
Private implementation stub |
|
Private implementation header |
|
C++ system test stub |
|
Python binding stub (under |
|
Python test stub |
|
C++ benchmark stub |
|
Python benchmark stub |
|
Benchmark config stub (tiers, layout axis, declared dtypes) |
The script also updates these existing files:
src/cvcuda/priv/CMakeLists.txtsrc/cvcuda/CMakeLists.txttests/cvcuda/system/CMakeLists.txtpython/mod_cvcuda/CMakeLists.txtpython/mod_cvcuda/Main.cpppython/mod_cvcuda/operators/Operators.hppbench/cpp/CMakeLists.txtandbench/python/CMakeLists.txtbench/config/bench_params.json(the benchmark manifest)docs/sphinx/operator_list.rst(the operator table row)docs/sphinx/modules/python/operators.rst(theautofunctiondirectives)the latest release notes (
docs/sphinx/relnotes/vX.Y.Z-*.rst) — a “New Features” bullet
Note
mkop.sh writes the Python binding directly under python/mod_cvcuda/operators/ and
wires the benchmark, documentation, and release-note stubs listed above. The generated stubs
are intentionally minimal (interleaved NHWC Tensor only, with TODO(make-op) markers);
extend them while implementing. The private implementation is generated as .cpp — rename
it to .cu (and update src/cvcuda/priv/CMakeLists.txt) only if you write CUDA device
code directly in it; operators that call into legacy kernels keep .cpp.
Step 2: Implement the Operator
Rename the private implementation to
.cufor CUDA kernels:mv src/cvcuda/priv/Op<Name>.cpp src/cvcuda/priv/Op<Name>.cu
Update the filename in
src/cvcuda/priv/CMakeLists.txtto match.Write the CUDA kernel in
src/cvcuda/priv/Op<Name>.cu.Make the operator multi-GPU safe — see Multi-GPU Safety below.
Document supported layouts and data types in the Doxygen comment for each function in
src/cvcuda/include/cvcuda/Op<Name>.h, using the standard “Limitations” table format:/* * Limitations: * * Input: * Data Layout: [kNHWC, kHWC] * Channels: [1, 3, 4] * * Data Type | Allowed * -------------- | ------------- * 8bit Unsigned | Yes * 8bit Signed | No * 16bit Unsigned | Yes * 32bit Float | Yes * ... */
Image operators declare both interleaved (
NHWC/HWC) and planar (NCHW/CHW) layouts by default. If image layouts do not apply, add this operator-local declaration beside the Limitations table instead of maintaining a central exception list:Planar image layouts: Not applicable Reason: <why this operator's tensors do not represent images>The implementation must enforce these constraints at runtime and return
NVCV_ERROR_INVALID_ARGUMENTfor unsupported combinations.Add negative tests to
tests/cvcuda/system/TestOp<Name>.cppthat verify unsupported formats and dtypes are rejected. Use a parameterizedNVCV_TEST_SUITE_Pvalue list for operators with many rejected combinations (seeTestOpResize.cpp), or individualTEST(..._Negative, ...)blocks for simpler cases (seeTestOpCLAHE.cpp). Each case should assertNVCV_ERROR_INVALID_ARGUMENT.Expose the parameters in the public C and C++ headers (
Op<Name>.handOp<Name>.hpp).Build and run the tests to confirm the implementation is correct:
cmake --build build-rel --target cvcuda_test_system ctest --test-dir build-rel -R TestOp<Name>
Multi-GPU Safety
Any operator that allocates device memory must do so with multi-GPU in mind.
The naive approach — calling cudaMalloc in the constructor and storing the
pointer as a member — allocates on whatever GPU happens to be current at
construction time. If the operator is later invoked on a different GPU the
kernel will access memory that lives on the wrong device, causing silent
corruption or a CUDA error.
The pattern: ``PerDeviceResource<T>``
src/cvcuda/priv/PerDeviceResource.hpp provides a template that lazily
creates one instance of T per CUDA device. The factory runs the first
time get() is called from a new device, with that device already set as
current. Destruction also sets the correct device before calling the
destructor, so cudaFree always targets the right GPU.
Put device allocations in a small helper struct and wrap it:
// Op<Name>.hpp
#include "PerDeviceResource.hpp"
struct <Name>DeviceBuffers
{
void *buf = nullptr;
<Name>DeviceBuffers(/* constructor params */)
{
NVCV_CHECK_THROW(cudaMalloc(&buf, size));
}
~<Name>DeviceBuffers()
{
if (buf) NVCV_CHECK_LOG(cudaFree(buf));
}
};
class <Name> final : public IOperator
{
// ...
mutable PerDeviceResource<<Name>DeviceBuffers> m_deviceBuffers;
};
Initialise it in the constructor with a factory lambda that captures the parameters needed to size the allocation:
// Op<Name>.cu (constructor)
<Name>::<Name>(/* params */)
: /* other members */
, m_deviceBuffers([/* capture params */](int /*deviceId*/)
{ return std::make_unique<<Name>DeviceBuffers>(/* params */); })
{
}
And call m_deviceBuffers.get() in operator():
void <Name>::operator()(cudaStream_t stream, ...) const
{
RunKernel(..., m_deviceBuffers.get().buf, stream);
}
See src/cvcuda/priv/OpCLAHE.hpp and OpCLAHE.cu for a complete
reference implementation.
Step 3: Write Tests
The scaffold generates stubs for both C++ and Python tests. Fill them in as
described below. See TestOpCLAHE.cpp and test_opclahe.py as reference
implementations.
C++ correctness tests (tests/cvcuda/system/TestOp<Name>.cpp)
The standard approach is:
Write a plain-C++ reference implementation of the operator (or, alternatively, test against known reference outputs).
Use
NVCV_TEST_SUITE_Pto define a parameterized table of inputs (sizes, batch sizes, dtypes, operator parameters):NVCV_TEST_SUITE_P(Op<Name>, test::ValueList<int, int, int /*, ...*/> { // width, height, batches, ... { 64, 64, 1 }, { 320, 240, 4 }, });
In the test body, fill input tensors with random data, run the operator, copy results back to host, and compare against the reference with a tight pixel tolerance:
TEST_P(Op<Name>, tensor_correct_output) { // ... create tensors, fill with random data ... cvcuda::<Name> op(/* params */); EXPECT_NO_THROW(op(stream, in, out, /* params */)); ASSERT_EQ(cudaSuccess, cudaStreamSynchronize(stream)); // compare out vs. reference result }
Add a separate
TEST(Op<Name>, varshape_correct_output)that builds annvcv::ImageBatchVarShapewith randomly sized images and runs the same comparison.Add
TEST(Op<Name>_Negative, ...)cases (covered in step 2 above).
Python smoke tests (tests/cvcuda/python/test_op<name>.py)
Python tests focus on the API surface rather than numerical correctness (the C++ tests own that). Cover:
Tensorinputs withNHWCandHWClayouts using@pytest.mark.parametrizeover a small set of shapes.ImageBatchVarShapeinputs.Both the allocating variant (
cvcuda.<name>(src, ...)) and the in-place variant (cvcuda.<name>_into(dst, src, ...)), asserting that the output has the expectedshape,layout, anddtype.Negative cases with
pytest.raises(Exception)for unsupported formats and invalid parameter values.
Step 4: Add Benchmarks
Every operator requires matching C++ and Python benchmarks. See
bench/README.md in the repository root for full benchmark documentation.
C++ benchmark — create bench/cpp/ops/Bench<Name>.cpp and register it
in bench/cpp/CMakeLists.txt.
Python benchmark — create bench/python/ops/bench_<name>.py and
register it in bench/python/CMakeLists.txt.
Shared configuration — add an entry to bench/config/bench_params.json.
This file is the single source of truth for parameter axes in both languages:
"<name>": {
"dtypes": ["uint8"],
"string_axes": {
"shape": ["16x1080x1920"]
},
"int64_axes": {
"varShape": [-1, 0]
}
}
Note
Choose batch sizes (the leading dimension of shape) so that each
benchmark configuration runs for roughly 1–2 ms on an H100 (or
equivalent GPU). Kernels that finish in tens of microseconds have high
relative timing noise; kernels that run for tens of milliseconds make the
full benchmark suite slow. The 1–2 ms range gives nvbench enough signal
to produce stable, low-noise measurements.
Step 5: Fill in the Documentation Stubs
mkop.sh has already inserted the documentation entries listed above —
the operator_list.rst table row, the operators.rst autofunction
directives (cvcuda.<name> and cvcuda.<name>_into), and the release-note
bullet — each with a TODO(make-op) placeholder. You do not add these
rows by hand; you only replace the placeholder text with real content:
Operator list — in
docs/sphinx/operator_list.rst, replace theTODO(make-op)description on the generated row:* - <Human-readable Name> (:py:func:`cvcuda.<name>`) - Brief description of what the operator does.
Python API reference — the
autofunctiondirectives indocs/sphinx/modules/python/operators.rstare already in place; confirm they render once the pybind docstrings (written with the binding in Step 2) are in place.Release notes — replace the
TODO(make-op)text in the generated “New Features and Enhancements” bullet of the latestdocs/sphinx/relnotes/vX.Y.Z-*.rst.