Skip to content

Unify Tensor backend services and integrate AMD HIP/ROCm - #3844

Open
francescofugazzi wants to merge 68 commits into
MrNeRF:devfrom
francescofugazzi:feat/hip-tensor-adapter
Open

francescofugazzi wants to merge 68 commits into
MrNeRF:devfrom
francescofugazzi:feat/hip-tensor-adapter

Conversation

@francescofugazzi

@francescofugazzi francescofugazzi commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR gives CUDA, Vulkan, Metal and AMD HIP/ROCm one shared Tensor dispatch and resource-ownership contract, and connects the opt-in HIP implementation to the public Tensor facade.

Shared Tensor code describes operations and owns opaque native implementations. Backend drivers implement device inventory and selection, activation, execution targets, transfers, queues, completion, timing, memory-pressure policy and protocol capabilities. Native SDK calls and backend-specific decisions stay in the corresponding implementation files.

This is the next Tensor-focused contribution from the work originally collected in #3759, following the native operators merged in #3809. The scope includes the existing CUDA/Vulkan/Metal integration paths as well as HIP. HIP training, application preferences and portable distribution profiles remain separate work.

Common backend architecture

  • Keep stable identities CUDA = 0, Vulkan = 1, Metal = 2, HIP = 3 in every build. CMake generates compiled-backend lists and registration references from the enabled options. The shared registry does not include SDK headers or construct concrete backends.
  • Use one indexed driver interface for inventory, startup selection and activation. Inventory does not create an execution context. Uncompiled backends report their unavailability explicitly; the fallback driver does not invent CUDA targets, device properties or renderer access.
  • Cache activated operation pointers per thread. The warm operation path returns the indexed pointer without invoking a registered getter, taking a lock or probing capabilities again. CUDA's required thread binding and Metal's platform availability checks remain native. Only immutable binding policy is cached; dynamic availability is not.
  • Make upload, fence, completion, work-queue, prepared-readback and elapsed-time services part of every backend implementation. Each shared object owns one native implementation rather than parallel members for different GPU APIs. Queue creation, scope binding, callbacks, consumer timeline imports and interop follow the same contract.
  • Let native allocation descriptors carry ownership, accounting, target and provenance. Shared adoption and destruction use that policy, including borrowed owners and exception rollback. Exact allocation retains its native meaning, and the hot StorageRef layout does not grow.
  • Route pressure recovery through the failing allocation domain, with explicit device-domain mappings and native recovery policy. Host or unmapped domains do not implicitly query CUDA. Python exports use explicit capabilities and native preparation rather than sending foreign storage to CUDA.

Unsupported timestamps, raw imports or protocol exports are reported explicitly through the interface. Public compatibility entry points retain their native contracts, including the no-CUDA compatibility default. The touched shared dispatch files are covered by the backend-neutrality gate.

AMD HIP/ROCm integration

LFS_TENSOR_HIP remains OFF by default, with opt-in support on Windows and Linux. macOS rejects that option. Standard build defaults and backend preference order remain unchanged; compiling HIP does not automatically prefer it.

  • Connect allocation, pointwise/strided operations, indexing, reductions, scans, sorting, GEMM, sampling, image, geometry and NN operations through the Tensor facade. Project-owned mathematical kernels and Slang programs are reused through source generation; fused expressions use HIPRTC.
  • Keep HIP execution and SDK loading inside the Tensor implementation. Public Tensor headers do not require HIP SDK headers, and the host compiler does not link a HIP import library. The separate native module does not require CUDA or PyTorch for HIP execution.
  • Provide native uploads, readbacks, packed slots, fences, completions, timestamps and queue callbacks. The HIP adapter serializes submissions on its selected native queue, rejects foreign streams/storage, and validates byte ranges, layouts, dtypes and ownership.
  • Track live allocations, cache and native workspaces; trim optional cached storage before an allocation retry. Synchronous scalar results use context-owned staging so failed copies cannot leave writes targeting a caller's stack.
  • Report Python HIP tensors as hip. HIP DLPack export is explicitly unavailable and directs callers to a CPU copy; it does not invoke CUDA pointer queries on HIP memory.
  • Use private native module ABI revision 6, checking the exact version and required exports before use. The generated Core ABI stamp tracks the changed C++ interfaces separately.

Build instructions, the ROCm SDK 10.1.0 development reference, architecture selection and capability limits are documented in the HIP backend guide. Compiled wave32/wave64 architecture targets are not a claim of hardware qualification on every corresponding GPU.

Ordering, numerical contracts and failure handling

The common contract covers Int32/Int64 indices, selected counts, overlapping indexed writes and native conversion scratch. Clamp/wrap normalization happens before narrowing wide indices, with corresponding changes in the existing CUDA, Vulkan and Metal index implementations. Bool conversion preserves nonzero semantics, including fractional values and NaN. Random operations retain native seed/offset and odd-count behavior; explicit CUDA generator compatibility remains native.

Completions use one factory driven by native target/value/timeline and release policy. Readbacks retain their source and destination, capture execution faults at their queue position, and check them before publishing bytes. Empty readbacks and zero-byte completions still carry error snapshots. Repeated polling or consumption of a later context error cannot turn an earlier failed snapshot into success.

Queue destruction distinguishes checked execution errors after a successful drain from failure of native synchronization. The former releases the queue lease; the latter retains live ownership for safe recovery. HIP shutdown trims cached buffers before closing the context, retains resources whose cleanup failed, and supports retry. A worker with cached HIP ops reinitializes before synchronization after another thread shuts the backend down.

Vulkan expression-cache reuse also validates the push-constant layout. Incompatible cached artifacts are rejected with a diagnostic and recompiled; failed fresh compilation is not accepted as a usable null kernel.

These changes affect shared integration and selected native Tensor semantics. This description does not claim that every existing backend source is untouched. Trainer and rendering algorithm integration is outside this PR.

Dispatch overhead and comparison with dev

Host-side refinements avoid no-op binding dispatch, same-target synchronization calls, unnecessary allocation-metadata construction and repeated vector growth during fused preparation. Allocation adoption uses scoped rollback so MSVC can inline the helper; the normal deleter still resolves the backend on the releasing thread. Optimized objects were inspected alongside runtime measurements.

The current baseline is upstream dev merge-base eecd2cc187652f00a9bcaa3692d438329df63b61, rebuilt from unmodified sources. Each new comparison reruns both dev and head in alternating processes on the same hardware. Windows uses matching Release /O2 /Ob2 builds with FAST_COMPILE off, CUDA 12.8, RTX 5060 Ti and driver 617.42; macOS uses matching Release builds on an M4 Pro. These are Tensor microbenchmarks, not training-throughput measurements.

Representative repeated dispatch_overhead_small comparisons:

Configuration and sampling dev median (ms) Head median (ms) Change
Windows Vulkan, 21 pairs, 101 samples/process 0.7330 0.7340 +0.14%
Windows Vulkan, 21 pairs, 1001 samples/process 0.7780 0.7681 -1.27%
Windows Vulkan, separate 9 pairs, 1001 samples/process 0.7706 0.7701 -0.06%
macOS Vulkan, 7 pairs, 101 samples/process 2.518666 2.511166 -0.30%
macOS Vulkan, 7 pairs, 1001 samples/process 2.528959 2.521209 -0.31%
macOS Metal, 7 pairs, 101 samples/process 0.518625 0.524000 +1.04%
macOS Metal, 7 pairs, 1001 samples/process 0.590792 0.572041 -3.17%

Unfavorable repetitions are retained too: a Windows Vulkan series was +3.32%, a separate check reached +5.05%, and the complete macOS suite measured Vulkan small dispatch at +2.31%. CUDA repetitions changed sign. In the final CUDA control, eight ABBA/BAAB blocks (32 process runs, 1001 samples each) gave a block-geometric-mean difference of +1.27%, with a 95% block-bootstrap interval of [-4.25%, +6.25%]. The unchanged dev executable itself varied by up to 12.92% between repeated runs. This establishes measurement variability, not strict equivalence or a guaranteed speedup.

CUDA profiling found matching kernel/synchronization counts and launch parameters; separate API-count checks also matched. The attempted Vulkan captures did not contain API events and are not used to attribute driver costs. Parity on the reviewer's native Linux RTX 4080/CUDA 13.3 setup still requires confirmation.

The latest detailed follow-up preserves complete Windows/macOS GUI tables, CUDA launch-overhead tables, profiling details and limitations. Earlier common-dispatch measurements and host-preparation measurements remain available with their original intermediate-PR baselines; they are not substituted for the current dev comparison.

Validation

Shared permanent contracts cover compiled backends, worker first use, inventory without context creation, shutdown/reopen, scope restoration, queue/fused-output ordering, retained ownership, indices, empty/exact allocations, conversion semantics, RNG behavior and fault recovery. Python tests exercise identity, capabilities and CPU DLPack. Separate instrumented runtime tests cover cleanup failures without adding fault hooks to production modules.

Latest relevant local checks on head 359524b76:

Check Result
Windows Release, CUDA + Vulkan 7/7 CTest groups
Windows HIP + Vulkan, CUDA disabled 10/10 groups, including Python bindings and recovery
macOS Release, Metal + MoltenVK 7/7 CTest groups
Linux/WSL, HIP enabled and CUDA disabled Incremental build passed; 3/3 CPU/runner/Python groups
Previously verified dedicated memory-pressure runner; relevant sources unchanged by final optimization 21 passed, 2 skipped for uncompiled backends
Backend-neutrality gate 0 violations
Error-debt, I/O discipline, CUDA log format and whitespace Passed
Gate regression tests 55/55
clang-format across the branch diff 162 changed C++/header/CUDA files passed; changed Objective-C++ ranges checked separately

The generated-header consumers and actual indexing-test registration were also checked directly. Earlier native-module, host-loader and numerical-operator suites are recorded in the linked follow-ups; their earlier results are not represented as fresh execution of every suite on this head.

The existing Ubuntu Release workflow now additionally compiles the main-tree HIP adapter and executes CPU/runner/Python contracts without a GPU, reusing its dependencies. The native portable compile/export checks remain separate. The standard Ubuntu builds still run when the HIP path-filter job fails; HIP-specific steps require the filter output. Required GPU tests fail explicitly when the requested backend is unavailable.

Local checks are targeted builds and runtime tests, not a complete portable rebuild or certification of every full application CI job. Native Linux GPU execution and all untested GPU architectures remain outside the available hardware coverage. Current CI status is shown by the PR checks.

Public documentation includes Tensor backend contracts and the HIP build guide. Only production code, permanent tests, gate tooling and public documentation are committed; development reports, raw benchmark data and experimental scripts remain outside the branch.

@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Thanks for the detailed review. This update addresses the common-dispatch requests for all four backends, including the existing CUDA/Vulkan/Metal branches in the shared dispatch files, rather than just replacing the HIP compile-time branches with runtime checks.

Common services and registration

  • Upload, fences, completions, work queues, prepared readback and elapsed timers now use mandatory native services implemented by CUDA, Vulkan, Metal and HIP. The shared objects own one native implementation; queue creation, scope binding and interop use the same contract. The optional throwing defaults have been removed.
  • One native driver/inventory interface handles activation, device selection, target policy, pressure services and export capabilities for each backend. CMake generates the enabled backend lists and registration references, so native construction and SDK includes stay in backend files.
  • Operation dispatch uses indexed slots and a thread-local activated-ops cache. CUDA first-use worker binding and Metal platform availability checks remain native. Unsupported features are reported explicitly through the interface.

Remaining shared operation paths

  • Index-width conversion, selected counts, alias protection and odd-count normal scratch follow common contracts, with native conversion/scratch implementations. Allocation descriptors carry ownership, accounting, target and provenance instead of shared allocation/deleter code identifying HIP.
  • The follow-up cleanup also moves the remaining cuRAND state, stream retirement and target binding, exact/arena allocation policy, reduction tuning and expression address/pairing policy out of the touched shared files. Explicit CUDA compatibility APIs retain their handle-address and lazy initialization contracts; no-CUDA builds report their unavailability explicitly.
  • Exact allocation remains a native intent: CUDA retains its exact allocation mode, while the other adapters retain their sizing behavior. The hot StorageRef layout is unchanged. Disabled allocation profiling no longer constructs profiling descriptors solely for exact allocations.
  • Pressure recovery follows the failing allocation domain. Python exports use capability checks and native preparation. Zero-byte completions retain the error snapshot, and queue destruction retains the distinction between execution failure and failure to drain live work.
  • A Vulkan expression-cache check rejects incompatible cached push-constant layouts, logs the fallback and recompiles. The invalid-artifact fallback and subsequent warm reuse were exercised; a failed fresh compilation is not accepted as a null kernel.

Regression tests and architecture checks

Shared contracts cover the compiled backends, including worker first use, inventory without context creation, shutdown/reopen, target ordering, retained ownership, error recovery, indices, exact/empty allocation, conversion semantics and RNG reseeding. Normal sequences for counts 1, 2, 3 and 16 were also compared bit-for-bit before/after on Windows CUDA and Vulkan.

The backend-neutrality check now explicitly guards the touched shared Tensor dispatch files against native SDK includes/calls, backend compile guards and operational backend choices. This is a structural check alongside the runtime tests, not a substitute for ownership/ordering validation. Public documentation now includes docs/docs/development/tensor-backend-contracts.md, linked from the HIP backend guide.

Latest local validation:

Configuration Result
Windows Release, CUDA + Vulkan, HIP disabled; RTX 5060 Ti 7/7 CTest groups passed
Windows HIP + Vulkan, CUDA disabled; Radeon 890M 9/9 CTest groups, including Python bindings, plus HipAdapter.Recovery 1/1
macOS Release, Metal + Vulkan, CUDA/HIP disabled; M4 Pro 7/7 CTest groups passed
Linux under WSL, HIP enabled at build time Build passed; 3/3 CPU/runner/Python groups passed
Host tests: configuration, ABI, generation, CI helpers and gates 95/95 passed

Clang-format passed for all 98 changed C/C++ files checked, and changed ranges in the four Objective-C++ files. Backend neutrality reports zero violations; error debt, CUDA log format and I/O discipline checks pass. The error-debt baseline change relocates the existing cuRAND check macro to its native source; it does not add a new allowance. Normalized hashes were checked across the isolated platform test sources, including new native files.

Before/after performance evidence

The comparison baseline is the previous PR head, 0e00210b8; the updated head is 6814e0f23. Optimized before/after assembly was generated for representative dispatch, transfer and allocation paths. The final backend_ops assembly retains the TLS indexed load and a cold activation branch; the warm path has no registered getter, lock, allocation or capability query before returning the cached operations object.

The following are tiny 16-element operations, not training throughput. Windows CUDA uses nine alternating before/after process pairs on the same machine/SDK. Host code is /O2 on both sides: the baseline build's generated /Od flags were replaced with /O2; the updated build uses fastcompile OFF. Values are medians in microseconds.

CUDA case Before (us) After (us) Change
add16 15.209961 15.357812 +0.97%
fence_record16 0.157031 0.167578 +6.72%
queue_execute16 11.511719 11.486719 -0.22%
upload16 10.142188 10.371094 +2.26%
readback16 9.884375 9.719531 -1.67%
fused16 13.342969 13.453125 +0.83%
normal16 16.655469 13.127344 -21.18%
cast_bool16 29.341406 13.951562 -52.45%
empty_exact16 0.892969 0.989062 +10.76%

The residual overhead is explicit: exact allocation costs approximately 0.10 us more per call, and fence recording approximately 10 ns more. Removing unnecessary disabled-profiler work did not remove that allocation overhead. The Float32-to-Bool improvement was consistent across the repeated series (roughly halving the cost); fused and normal timings varied between series, so I am not claiming a reproducible speedup for those cases or a training improvement.

The updated macOS Release comparison uses three alternating pairs on the same M4 Pro. These are CPU submission medians in microseconds; the underlying measurements also retain completion timings.

Backend / case Before (us) After (us)
Metal / add16 0.987346 0.931152
Metal / fence_record16 0.016602 0.016438
Metal / queue_execute16 12.873703 14.322914
Metal / upload16 0.284180 0.292320
Metal / readback16 0.050133 0.048508
Vulkan / add16 5.268799 5.481608
Vulkan / fence_record16 0.020344 0.020184
Vulkan / queue_execute16 29.560867 29.780273
Vulkan / upload16 78.513344 78.853188
Vulkan / readback16 80.597328 81.305016

The Mac runs show dispersion, particularly for queue execution, so these results should not be read as proof of either a small speedup or zero overhead. Numerical checks passed. I am reporting the measured costs rather than claiming the abstraction is free.

Scope of this validation: these are targeted Core/contract builds and runtime checks, not a full portable rebuild or a complete application CI matrix. WSL was used to compile HIP and run CPU contracts; Linux GPU execution was not tested. Runtime coverage is limited to the hardware listed above. Benchmark scripts, assembly dumps and development reports remain outside the committed repository; regression tests and public documentation are committed.

@francescofugazzi

francescofugazzi commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

This follow-up adds two measured, local-first refinements to the common Tensor layer after the R4 restructuring:

  • ae070b978 — reduce allocation teardown and fence dispatch overhead.
  • 956363e5f — reduce same-target dispatch and fused launch preparation overhead.

The scope is shared host-side preparation and dispatch. These commits do not change native numerical kernels, rasterization/training algorithms, SDK versions, or the public backend interface/StorageRef layout.

Changes retained

Allocation teardown. The common deleter now constructs a temporary StorageMeta only when the allocation actually has native metadata. Pointer-only allocations avoid that construction/destruction. The retained version keeps the existing deleter captures and owner structure, descriptor snapshot for native teardown, accounting and error cleanup. An earlier experiment with a separate pointer-only deleter was not retained.

Fence dispatch. The TensorExecutionTarget(queue) constructor is defined beside the queue accessors, allowing those calls to inline within the same translation unit. Optimized assembly confirms that queue.record no longer needs the intermediate target/accessor calls. The backend-mismatch check and the native virtual fence operation remain.

Same-target synchronization. sync_to_stream still validates the Tensor, but avoids native driver dispatch when the requested target is already the Tensor's home target. Different targets retain native synchronization. This is a common identity check, not a backend-specific fast path.

Fused launch preparation. Host binding/output vectors reserve their known capacities before being populated, avoiding growth/reallocation. Binding validation, alias checks, dimensions, host parameters and generated arithmetic remain unchanged. Execution-to-home ordering for fused outputs now uses the common different-target check rather than a CUDA identity branch. The other native adapters retain their canonical home-target behavior.

Contracts and gate. Added FusedOutputsRetainTheirQueueOrdering to the shared backend semantics tests. It uses two queues, two fused outputs and a CPU parameter, then consumes the second output on its own queue. Initializers are settled before the fused launch; there is no host barrier between that launch and the consuming queue operation. The test passed on CUDA, Vulkan, Metal and HIP. The GPU-expression case is explicitly skipped on CPU; required GPU cases still fail if their requested backend is unavailable. The neutrality gate now also guards tensor_fused.cpp, and the public target-ordering documentation describes the same-target no-op contract.

Experiments discarded

Caching the activated operations pointer locally through expression describe() did not improve warm preparation: its measured series was approximately 1.324 -> 1.335 us. That code was reverted before committing. The first allocation experiment with a separate deleter type was also replaced by the more conservative implementation described above. Experimental sources, scripts, assembly and reports remain outside the committed repository.

Allocation/fence measurements

This series compared the old PR head, the published R4 snapshot (6814e0f) and the allocation/fence refinement. Values are medians in microseconds from nine alternating runs per version. The table reports one matched series rather than combining values from different sessions.

CUDA tiny operation Old head 0e00210 Published R4 Allocation/fence refinement
Exact allocation, 16 elements 0.946094 0.971875 0.900781
Fence record 0.158594 0.170703 0.165625

Exact allocation improved by approximately 7.3% against the published snapshot in this series; the other retained series also show recovery, with different absolute values. Fence recording improved by approximately 3%, while retaining about 7 ns overhead against the old head in this series. This does not establish that every abstraction overhead is gone.

Isolated CPU preparation measurements

To separate host preparation from GPU launch variability, additional benchmarks measured same-target synchronization and warm preparation of an already compiled fused expression. Windows fixes only the benchmark's main thread to logical CPU 2; it does not change application or system scheduling settings. Each process takes seven samples, with nine alternating comparisons per version. Same-target sync uses 262144 calls/sample; warm preparation uses 8192. Both Windows sides use optimized /O2 host code. macOS uses optimized Release code without forced CPU affinity.

CPU-side operation ae070b9 956363e Change
CUDA same-target sync 4.420 ns 2.160 ns -51.1%
CUDA warm fused preparation 1.325842 us 1.265833 us -4.5%
Metal same-target sync 2.438 ns 1.551 ns -36.4%
Metal warm fused preparation 0.466049 us 0.449707 us -3.5%
Vulkan/macOS same-target sync 2.438 ns 1.551 ns -36.4%
Vulkan/macOS warm fused preparation 0.471354 us 0.446691 us -5.2%

For reference, the old CUDA head's warm preparation was 1.306470 us in the same final Windows preparation series. The updated path was therefore also lower than that old baseline in this measurement. These are host-side costs, not kernel execution or training iteration times.

GPU-inclusive timings and uncertainty

Longer, single-case comparisons also measured 16-element operations including submission of the GPU work: 2048 calls/sample, seven samples/process, nine alternating comparisons per version, with the benchmark main thread fixed to the same CPU. The final series was:

CUDA operation, us Old head 0e00210 ae070b9 956363e
Fused expression 14.375879 14.289062 14.210840
Add 14.658740 15.485156 15.760547
Float32 to Bool 27.367969 14.011621 14.805762

These GPU-inclusive results remain inconclusive for the incremental change: the updated version was faster in only 4/9 matched pairs for each case, and exploratory paired bootstrap intervals include zero broadly. Other retained series reverse some differences. The larger Bool improvement against the old head remains evident; neither its kernel nor the add kernel was changed in these two commits. I am not presenting the small fused median difference as a demonstrated GPU speedup, nor claiming that every GPU-inclusive timing is unchanged.

The Mac investigation also retained initially unfavorable Vulkan-add series. Comparing the same saved libraries with the two changes separated subsequently reversed that difference. All those series are preserved in the local report; none was dropped to select a favorable result. Background filesystem activity was possible on Windows, so the measurements are not described as perfectly noise-free.

Validation of the retained code

Configuration Result
Windows CUDA + Vulkan, HIP disabled; RTX 5060 Ti 7/7 CTest groups passed
Windows HIP + Vulkan, CUDA disabled; Radeon 890M 10/10 groups, including Python bindings and recovery
macOS Metal + Vulkan; M4 Pro 7/7 groups passed
Linux/WSL, HIP enabled at build time Build passed; 3/3 CPU/runner/Python groups passed
Neutrality/error-debt tool unit tests 55/55 passed

Clang-format, backend neutrality, error-debt, CUDA log-format, I/O discipline and diff checks passed. The neutrality check reports zero violations, with no additional error-debt allowance. Normalized hashes of 836 Core/test/gate files match the isolated Mac and WSL sources.

The new multi-output ordering case ran successfully on all four GPU backends. Linux GPU runtime execution was not tested under WSL.

These were targeted incremental builds and tests. There was no full portable rebuild, no new long training comparison, and no claim of a complete application CI matrix simulation. The measured CPU improvements should not be interpreted as measured training throughput gains. Only production changes, regression tests and public documentation are committed.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is the refactor we asked for. Common services for all four backends, one driver interface, an indexed ops table with the per-thread cache, generated registration, and the neutrality check covering the dispatch files. The shape is right now. What's left is two build/test breaks, a few backend special cases that survived in shared code, one HIP lifetime bug and a Vulkan dispatch cost against dev.

I built 956363e on Linux (CUDA 13.3 + Vulkan, Release, RTX 4080). TensorOperationContracts.{CPU,CUDA,Vulkan} and TensorQueueContracts pass, 4/4.

Breaks

  • The build fails at src/training/rasterization/gsplat/Common.cpp: core/gpu_backend_fwd.hpp:29 now includes the generated core/gpu_backend_config.hpp, which lives in ${CMAKE_BINARY_DIR}/include. gsplat_backend_lfs adds src/core/include by hand and never links lfs_backend_configuration, so it can't find the header. Same failure in the Ubuntu Debug job. Linking lfs_backend_configuration there fixes it; please check any other target that adds src/core/include by hand.
  • macOS fails at test registration: "Duplicate parameterized test name 'MetalGather'". tests/test_tensor_vulkan_index.cpp:835 names every backend that isn't CUDA or Vulkan "Metal", and kGpuBackends now has HIP in every build. Use gpu_backend_name() like tests/test_memory_pressure.cpp:374 does.

Backend special cases still in shared code

  • UnavailableDriver (backend_registry.cpp:16-84) carries CUDA behaviour through its constructor arguments. The uncompiled CUDA slot reports device 0, returns getCurrentCUDAStream() as its target and reports renderer storage access. The Metal and HIP slots also get "cuda.direct" as their default direct label. The fallback driver should be neutral. If no-CUDA builds still need selected_cuda_device() == 0 (dev returns 0 there), keep that in the CUDA compatibility function, not in the shared fallback. The two std::runtime_error throws in the same class should be TensorError like throw_unavailable().
  • Tensor::storage_memory_summary (tensor.cpp:347-353) asks each driver for its supplementary accounting kind and then checks for HipOwned to print hip_owned. Map the kind to its label and counter, so this loop doesn't name a backend.
  • TensorCompletionAccess (backend/tensor_completion.hpp, tensor_backend.cpp:553-590) has one factory per backend in shared code, each with its own settle_on_release and timeline handling. One factory that takes the backend and the native record (target, value, timeline point, settle policy) moves that policy into the backends that own it.
  • hip_driver.cpp:34 labels HIP direct allocations "cuda.direct", while hip_backend_ops.cpp:612 uses AllocationLabel::HipDirect. Take the string from the label.
  • Smaller: memory_pressure.cpp:49,174 fall back to CUDA for any domain that domain_backend() doesn't map. Map the CUDA domain explicitly instead of making it the default.

HIP after shutdown on another thread

shutdown_gpu_backend() only clears the calling thread's activation cache. A worker that already cached HIP ops never goes through activate() again, so it never calls ensure_initialized(). allocate() recovers because it calls ensure_initialized() itself, but sync() (hip_backend_ops.cpp:372), synchronize_device and device_barrier don't, and throw "HIP Tensor context is closed" after a shutdown/reopen. Calling ensure_initialized() there (one acquire load on the warm path) fixes it. Please add a contract test: a worker caches HIP ops, the main thread shuts down and reopens, then the worker synchronizes.

Performance against dev

Your tables compare against earlier heads of this PR. I measured against the merge base (eecd2cc) instead: same machine, alternating base/head processes, both Release, GPU otherwise idle. Medians in ms, 7 pairs:

Case dev 956363e Change
CUDA eager_binary_dispatch_tight_loop 12.800 12.869 +0.5%
CUDA masking_ops_tight_loop 1.550 1.561 +0.7%
CUDA dispatch_overhead_small (gui bench) 0.8589 0.8668 +0.9%
CUDA broadcast_binary_add 0.7290 0.7286 -0.1%
CUDA sort_middle_axis_64x1024x64 0.6647 0.6657 +0.2%
Vulkan dispatch_overhead_small (gui bench) 0.5661 0.5796 +2.4%

CUDA is within run-to-run noise now. At 6814e0f the eager loop was +1.6 to +2.6% in two runs, so 956363e fixed that. Vulkan small dispatch is slower than dev in all three runs I did (+1.6%, +1.8%, +2.4%), with little overlap between the sample ranges. The bandwidth-bound GUI cases (RGB conversion, fused pointwise, mask reduce, non-contiguous copy) are within ±1.5% on both backends. Please profile bench_tensor_gui --backend vulkan --case dispatch_overhead_small against dev and get it back to parity. I'd start with the per-op paths that went from direct calls to the driver interface (stream/target binding, the scope and availability checks).

Requesting changes for the two breaks, the shared-code special cases and the HIP thread bug. The Vulkan number has to reach parity before merge too.

@francescofugazzi

francescofugazzi commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Follow-up: review corrections and fresh comparisons against upstream dev

Pushed 4c417283e and 359524b76 on top of the reviewed 956363e5f (31 files, +277/-129 for this follow-up). This addresses the functional findings in the latest review and adds a small allocation-adoption optimization to reduce dispatch overhead. The benchmark baseline below is upstream dev merge-base eecd2cc187652f00a9bcaa3692d438329df63b61, rebuilt from its unmodified sources, rather than an earlier head of this PR.

Build and test registration

  • Linked both gsplat_backend_lfs and the corresponding fastgs target to lfs_backend_configuration with public propagation. Audited 1,043 core-header-consuming translation units in the configured build; none lacked the generated include directory. Compiled the five gsplat host objects, its CUDA spherical-harmonics object and the fastgs CUDA Adam object to check the previously failing consumers. This targeted verification is not a full application/trainer rebuild.
  • Parameterized indexing-test names now use gpu_backend_name(). HIP keeps its distinct name even when uncompiled. Compiled and registered the actual indexing-test source on Windows and macOS to check for duplicate names.

Shared contracts and native policy

  • Made UnavailableDriver neutral: no invented device 0, CUDA stream, renderer-storage access or cuda.direct label. Its unavailable operations throw TensorError. The existing no-CUDA selected_cuda_device() == 0 compatibility behavior remains in the native CUDA compatibility stub.
  • Moved supplementary-accounting label/counter selection into a mapping outside the driver loop. The shared loop no longer checks for HipOwned.
  • Replaced the per-backend TensorCompletionAccess factories with one factory receiving a NativeCompletionRecord. Native implementations supply the execution target, value, timeline and record/release policy. Zero-valued records still perform the required native recording; imported timeline obligations retain their existing behavior.
  • Derive empty direct-allocation labels from the native allocation flags. This fixes the HIP cuda.direct mismatch and the equivalent metadata inconsistency for empty Metal direct allocations. Added a shared contract test.
  • Map CUDA Device/VMM domains explicitly in memory-pressure handling. Host/unmapped domains do not implicitly become CUDA GPU pressure. CUDA's existing recovery-blocked-after-fault policy remains native and is exposed through the neutral interface.

HIP lifetime regression

HIP synchronization now calls ensure_initialized() before taking the context lock. synchronize_device and device_barrier use the corrected path too. Added a permanent regression test where a worker keeps cached HIP ops, another thread shuts the backend down, and the worker's next operation is synchronization. No intervening allocation is used to accidentally reopen the context and conceal the failure. The reproducer failed with the closed-context error before the correction and passes afterward.

Dispatch changes and allocation cleanup

  • Cache only the driver's immutable needs_thread_binding policy per thread. CUDA still performs its existing thread binding on each scope; Vulkan/Metal/HIP avoid invoking an empty binding operation. Dynamic availability is not cached.
  • Keep allocation rollback in a scoped RAII guard instead of an explicit catch in the adoption helper; pass the already-resolved ops reference within that call. On the Windows Release object, the two out-of-line helper-call relocations become zero. This matters because MSVC would not inline the previous exception-handling body, even with __forceinline alone.
  • The normal storage deleter still resolves ops on the releasing thread, preserving native thread binding. Borrowed owners, accounting rollback and metadata ownership remain part of the same contract. No kernel mathematics or random-number algorithm was changed by these two commits.
  • Separately fault-injected three host allocation points in an external diagnostic executable. Each propagated std::bad_alloc, returned the Vulkan cached block, and allowed a subsequent allocation, fill and CPU readback to succeed. No diagnostic allocator hooks were added to production code or committed tests.

Validation on the updated content

Configuration Result
Windows Release, CUDA + Vulkan 7/7 CTest groups
Windows, HIP + Vulkan with CUDA disabled 10/10 groups, including HIP recovery
macOS Release, Metal + MoltenVK 7/7 CTest groups
Linux/WSL, HIP enabled and CUDA disabled Incremental compilation passed; 3/3 CPU/runner/Python groups
Dedicated memory-pressure runner 21 passed; 2 skipped for uncompiled Metal/HIP backends
Backend-neutrality gate 0 violations
Error-debt gate No new violations
I/O discipline, CUDA log format, git diff --check Passed
Gate regression tests 55/55 passed
clang-format across the branch diff 162 changed C++/header/CUDA files passed; changed Objective-C++ ranges were checked separately

GPU runtime behavior on native Linux was not exercised: WSL here supports the CPU checks, not the required native Vulkan GPU comparison. These local checks do not substitute for the complete CI runs triggered by this push.

Performance methodology

Both dev and updated head are rerun for each new comparison, in alternating processes on the same machine. Historical dev timings are not reused as the current baseline. Windows timing builds are Release /O2 /Ob2, FAST_COMPILE off, CUDA 12.8, with an RTX 5060 Ti and driver 617.42. Each executable uses its own build's headers and core library, with matching dependency providers. macOS comparisons use the same Apple M4 Pro and matching Release configurations.

The original tests/bench_tensor_gui.cpp benchmark is unchanged. The baseline executable encountered a teardown access violation after printing the result, so both versions use the same external wrapper to perform explicit GPU teardown after the measured work. That wrapper and the raw diagnostics are outside the repository. Affinity experiments mentioned below pin only the benchmark's main thread and are diagnostic variants, not application changes.

Requested small-dispatch comparison

All values are head versus the upstream dev merge-base. Negative percentages mean lower time. Rows are separate repeated series and should not be combined as if they used identical sampling protocols.

Backend / series dev median (ms) Updated head median (ms) Change
Windows Vulkan, 21 pairs, 101 samples/process 0.7330 0.7340 +0.14%
Windows Vulkan, 21 pairs, 101 samples, diagnostic main-thread affinity 0.7310 0.7262 -0.66%
Windows Vulkan, 21 pairs, 1001 samples/process 0.7780 0.7681 -1.27%
Windows Vulkan, separate 9-pair series, 1001 samples/process 0.7706 0.7701 -0.06%
macOS Vulkan, 7 pairs, 101 samples/process 2.518666 2.511166 -0.30%
macOS Vulkan, 7 pairs, 1001 samples/process 2.528959 2.521209 -0.31%
macOS Metal, 7 pairs, 101 samples/process 0.518625 0.524000 +1.04%
macOS Metal, 7 pairs, 1001 samples/process 0.590792 0.572041 -3.17%

The Windows Vulkan 1001-sample confirmation had three constituent series at -4.99%, -1.54% and -1.20%. Other Vulkan repetitions still showed dispersion: one 101-sample series was +3.32%, and another three-series check produced -4.65%, -3.99% and +5.05%. The complete-suite measurements below are also retained, including +2.31% for macOS Vulkan small dispatch. I am not selecting only the favorable runs or claiming that every measurement improved.

CUDA small-dispatch repetitions changed sign as well: two normal 1001-sample series gave -4.08% and +4.78%, and a separate nine-pair series gave +2.46%. To measure variability within an unchanged build, the final control used eight alternating ABBA/BAAB blocks, 32 process runs total, 1001 samples each, where A=dev and B=head. The block-geometric-mean difference was +1.27%, with a block-bootstrap 95% interval of [-4.25%, +6.25%]. Repeats of the same dev build varied by up to 12.92%; the same head by up to 11.44%. This interval includes zero but is too broad to prove strict performance equivalence.

CUDA profiling cross-check

Two CUDA Nsight captures, in reversed dev/head order, showed the same 28,416 kernel launches, 213 device synchronizations, launch geometry and register count. Kernel median was 800 ns. Host time outside traced CUDA APIs was lower in head in both captures (approximately 0.503→0.493 ms and 0.503→0.487 ms per measured dispatch batch), while time inside launch APIs varied. This locates the observed difference in those traces but does not establish that submission cadence and driver time are independent of the refactoring.

A separate process-local API-count probe checked five runtime APIs not normally recorded by the installed profiler. Dev/head counts matched exactly: cudaGetLastError 63,490; cudaPeekAtLastError 8,001; cudaGetDevice 7,938; cudaSetDevice 0; cudaStreamQuery 10. Instrumented probe timings are not used as performance evidence. The attempted Vulkan captures contained no API events and are not presented as successful driver profiling.

Complete Windows GUI benchmark comparison: 7 alternating pairs, 21 samples/process
Backend Case dev (ms) Updated head (ms) Change
vulkan chw_rgb_to_hwc_u8_720p 0.169500 0.168800 -0.41%
vulkan chw_rgb_to_hwc_u8_1080p 2.659500 2.649300 -0.38%
vulkan mask_logical_reduce_1m 0.099400 0.095000 -4.43%
vulkan mask_logical_reduce_16m 0.531000 0.529200 -0.34%
vulkan float_to_uint8_1080p 0.079100 0.079400 +0.38%
vulkan fused_pointwise_720p 0.112300 0.108500 -3.38%
vulkan fused_pointwise_1080p 2.669200 2.600400 -2.58%
vulkan noncontig_chw_hwc_copy_720p 0.225900 0.227000 +0.49%
vulkan noncontig_chw_hwc_copy_1080p 2.908400 2.892500 -0.55%
vulkan dispatch_overhead_small 0.733600 0.739600 +0.82%
cuda chw_rgb_to_hwc_u8_720p 0.114300 0.114300 +0.00%
cuda chw_rgb_to_hwc_u8_1080p 0.348200 0.348800 +0.17%
cuda mask_logical_reduce_1m 0.091000 0.091900 +0.99%
cuda mask_logical_reduce_16m 2.248600 2.262100 +0.60%
cuda float_to_uint8_1080p 0.041500 0.040800 -1.69%
cuda fused_pointwise_720p 0.051000 0.050900 -0.20%
cuda fused_pointwise_1080p 0.233500 0.234000 +0.21%
cuda noncontig_chw_hwc_copy_720p 0.077500 0.077600 +0.13%
cuda noncontig_chw_hwc_copy_1080p 0.333600 0.334100 +0.15%
cuda dispatch_overhead_small 3.092300 3.159900 +2.19%

The small positive CUDA mask/copy results were repeated in isolated 101-sample runs: mask_logical_reduce_16m -0.03%, noncontig_chw_hwc_copy_1080p +0.06%. This illustrates why a single suite median is not enough to classify a small regression.

CUDA launch-overhead suite: 7 alternating pairs, 101 samples/process
Backend Case dev (ms) Updated head (ms) Change
cuda sort_middle_axis_64x1024x64 1.249200 1.247700 -0.12%
cuda broadcast_binary_add 1.194600 1.235500 +3.42%
cuda masking_ops_tight_loop 6.459600 6.800600 +5.28%
cuda eager_binary_dispatch_tight_loop 57.260600 59.857200 +4.53%
cuda deferred_alias_copy_materialize 0.018200 0.018200 +0.00%

Separate nine-pair diagnostic-affinity repetitions gave sort -0.02%, broadcast +0.16%, masking -4.03%, eager dispatch -16.41% and alias +5.52% (about 1 microsecond absolute for alias). The large eager improvement is not claimed as an acquired code speedup; it and the opposite-sign normal results remain part of the variability evidence.

Complete macOS GUI benchmark comparison: 5 alternating pairs, 21 samples/process
Backend Case dev (ms) Updated head (ms) Change
vulkan chw_rgb_to_hwc_u8_720p 1.168875 1.049291 -10.23%
vulkan chw_rgb_to_hwc_u8_1080p 4.403125 4.349667 -1.21%
vulkan mask_logical_reduce_1m 0.227083 0.234833 +3.41%
vulkan mask_logical_reduce_16m 1.837958 1.861083 +1.26%
vulkan float_to_uint8_1080p 0.246750 0.244958 -0.73%
vulkan fused_pointwise_720p 0.793375 0.350208 -55.86%
vulkan fused_pointwise_1080p 2.715541 2.677458 -1.40%
vulkan noncontig_chw_hwc_copy_720p 1.324083 1.341917 +1.35%
vulkan noncontig_chw_hwc_copy_1080p 2.559959 2.556875 -0.12%
vulkan dispatch_overhead_small 2.287750 2.340709 +2.31%
metal chw_rgb_to_hwc_u8_720p 0.885209 0.798625 -9.78%
metal chw_rgb_to_hwc_u8_1080p 1.392375 1.391042 -0.10%
metal mask_logical_reduce_1m 0.141375 0.139542 -1.30%
metal mask_logical_reduce_16m 1.802208 1.716000 -4.78%
metal float_to_uint8_1080p 0.173125 0.172625 -0.29%
metal fused_pointwise_720p 0.227500 0.227209 -0.13%
metal fused_pointwise_1080p 0.805041 0.808417 +0.42%
metal noncontig_chw_hwc_copy_720p 1.474541 1.518500 +2.98%
metal noncontig_chw_hwc_copy_1080p 1.697917 1.622292 -4.45%
metal dispatch_overhead_small 0.401417 0.393000 -2.10%

Some fused-pointwise measurements are bimodal in both versions, so the large percentage differences are not treated as demonstrated improvements. Small-dispatch follow-up measurements with 101 and 1001 samples are reported separately above.

Review status and remaining confirmation

The functional review findings have been addressed and the relevant local contracts/build consumers pass. The focused Windows/macOS Vulkan results support returning dispatch overhead to the dev range, but they do not certify parity on the reviewer's native Linux RTX 4080/CUDA 13.3 setup. That original hardware comparison remains the final confirmation for the hard performance requirement. The CUDA measurements likewise justify reporting variability, not asserting a guaranteed speedup or absolute absence of regressions.

Only production sources, permanent regression/CI tests, gate tooling and public backend-contract/build documentation are committed. Test reports, raw timings, diagnostic wrappers and experimental scripts remain outside the branch. The existing PR description and its earlier measurement tables are preserved.

@francescofugazzi francescofugazzi changed the title Add the AMD HIP/ROCm backend to the Tensor facade Unify Tensor backend services and integrate AMD HIP/ROCm Oct 28, 2026
@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Ubuntu Debug: keep backend drivers valid through process exit

Fixed the teardown regression in 74ab187ed.

The failed job had built successfully; three standalone Python checks then aborted with pure virtual method called. A cleanup callback registered during module import could run after the function-local backend drivers had already been destroyed. This was introduced by the common-driver refactor. The latest upstream dev Ubuntu build passes and does not contain a corresponding correction.

Registered drivers, including unavailable-backend placeholders, now have process lifetime, matching the existing activated-ops lifetime. These are bounded control objects; native resource cleanup remains explicit through backend shutdown. Operation dispatch and numerical kernels are unchanged by this correction.

Added a fresh-process CPU regression executable that registers its exit callback before the first driver lookup and uses normal process exit. GoogleTest discovery and the existing contract runner's explicit exit path could otherwise mask this ordering. The executable is built with tensor_operation_contracts and invoked by the existing CPU runner, covering the current Ubuntu, Windows and macOS CI configurations without a GPU.

Recompiled only the registry and driver objects without optimization in an isolated Linux library: the previous code reproduced the abort for all four backend slots, and the corrected code passed. The permanent exit test also failed against the old library and passed against the new one.

Additional local checks passed:

  • Windows CUDA/Vulkan: 7/7 CTest groups.
  • Windows HIP/Vulkan without CUDA: 10/10 groups.
  • macOS Metal/Vulkan: 7/7 groups.
  • Linux/WSL: 3/3 CPU/runner/Python groups.
  • Host/configuration/ABI/gate tests: 83/83; clang-format, backend neutrality, error debt, I/O discipline, CUDA log format and whitespace checks passed.

The original three full-module Python checks were not rebuilt locally; the new CI run provides their direct confirmation. No tests were disabled or relaxed.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this round fixes everything from my last review except the Vulkan number.

I rebuilt 74ab187 on Linux (CUDA 13.3 + Vulkan, Release, RTX 4080):

  • gsplat and fastgs build again, macOS registers the index tests, and Ubuntu Debug passes. Windows Release only fails the UNC rename test that every Windows job hits today (fix in #3873).
  • TensorOperationContracts.{CPU,CUDA,Vulkan,Runner} and TensorQueueContracts pass 5/5, tensor_backend_shutdown_contracts passes, and the backend gtests (semantics including the new cached-worker shutdown test on CUDA and Vulkan, memory pressure, index bounds) pass 71/71.
  • The shared-code special cases are gone: the fallback driver is neutral, the summary maps kinds through a table, there is one completion factory, and HIP takes its label from the allocation label.

Performance against dev

Same machine, dev merge base eecd2cc rebuilt for this run, 9 alternating base/head process pairs, GPU otherwise idle. Medians in ms:

Case dev 74ab187 Change
Vulkan dispatch_overhead_small (gui bench) 0.5699 0.5841 +2.5%
CUDA dispatch_overhead_small (gui bench) 0.8573 0.8674 +1.2%
CUDA eager_binary_dispatch_tight_loop 12.871 12.879 +0.1%
CUDA masking_ops_tight_loop 1.565 1.564 -0.1%
CUDA broadcast_binary_add 0.7255 0.7253 -0.0%
CUDA sort_middle_axis_64x1024x64 0.6639 0.6642 +0.0%

CUDA is within noise; the gui-bench CUDA case flips sign between pairs. Vulkan small dispatch is still slower. Head lost 8 of the 9 pairs (+1.6, +2.1, +2.4, +2.7, -0.8, +5.6, +2.8, +1.7, +2.7%), and the head median equals the slowest dev sample. That matches 956363e (+1.6, +1.8, +2.4% in three runs), so the r6 changes didn't move it on Linux.

One lead: the inlining fix only reaches MSVC. With GCC, Tensor::adopt_native_tensor_allocation is an exported member of Tensor, so under -fPIC it stays out of line, and both call sites in tensor_unified_ops.cpp call it through the PLT. Making it a non-exported helper in that file would let every compiler inline it, and the #if defined(_MSC_VER) block goes away too. I can't say that is all of it: the gap is about 55 ns per op (256 adds per sample), and I couldn't profile on this machine. Since you can't run native Linux Vulkan, push a candidate and I'll rerun this comparison.

Requesting changes for Vulkan parity only. The rest is ready.

@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Updated in commit 20eda28.

Allocation adoption

adopt_native_tensor_allocation is now a static lambda local to Tensor::load, with an explicit destination and no captures. Its declaration has been removed from the exported Tensor class, together with the MSVC-specific __forceinline block.

The GCC Release object and shared library confirm that the adoption helper is no longer dynamically exported and neither call site uses the PLT. GCC still emits two direct local calls, so this is not a claim of full inlining. Ownership, allocation accounting, exception rollback, descriptors, execution targets and backend lookup on the releasing thread retain the same behavior.

The commit changes only the two production files involved in moving the helper.

Current performance measurements against dev

Baseline: dev merge base eecd2cc18, rerun alongside this candidate. These are comparisons with dev, not with intermediate PR revisions.

For dispatch_overhead_small: three independent series per backend, each with four alternating ABBA/BAAB blocks, four fresh processes per block, 1,001 timed samples per process and 256 additions per sample. Compilation was finished before measurement. Outputs are validated outside the timed region.

The time columns below are medians of the process results across the three series. Each percentage is the geometric mean of the matched head/dev ratios within that series; it is therefore not calculated from the two pooled median columns. Negative percentages mean lower elapsed time.

Backend dev median (ms) Candidate median (ms) Series 1 Series 2 Series 3
Windows Vulkan 0.741150 0.731400 -0.62% +0.60% -1.25%
Windows CUDA 3.258450 3.187000 +2.30% -1.97% -2.50%
macOS Vulkan 2.537438 2.536230 -0.04% -0.05% -0.13%
macOS Metal 0.570334 0.593000 +0.79% +9.93% -9.10%

Vulkan is close to dev in these Windows and macOS runs. CUDA and especially Metal show more variation between series; these results do not establish a speedup. The local results do not establish native Linux Vulkan parity, which remains to be verified.

Validation

  • Windows Release CUDA/Vulkan: 7/7 contract tests passed.
  • Windows Release HIP/Vulkan without CUDA: 10/10 contract, Python binding and recovery tests passed; LFS_FAST_COMPILE=OFF.
  • macOS Release Metal/Vulkan: 7/7 contract tests passed.
  • Linux under WSL: 3/3 CPU/runner/Python tests passed. No Linux GPU result is claimed.
  • Process-exit checks passed, as did clang-format, diff whitespace checks and the no-new-error-debt gate.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for moving the adoption helper into the file. That part works: with GCC it is now inlined and there is no PLT call left. The Vulkan gap is smaller, but not gone.

I rebuilt 20eda28 on Linux (CUDA 13.3 + Vulkan, Release, RTX 4080).

Tests

  • Contracts 5/5, tensor_backend_shutdown_contracts and the 71 backend gtests pass.
  • CI: Windows only fails the UNC rename test that every Windows job hits right now.

Performance against dev

Dev merge base eecd2cc vs head, 15 alternating base/head process pairs, machine otherwise idle. Medians in ms:

Case dev 20eda28 Change Head slower
Vulkan dispatch_overhead_small (gui bench) 0.5685 0.5780 +1.7% 11 of 15 pairs
CUDA deferred_alias_copy_materialize 0.01164 0.01238 +6.4% 13 of 15 pairs
CUDA dispatch_overhead_small (gui bench) 0.8678 0.8661 -0.2% 5 of 15
CUDA eager_binary_dispatch_tight_loop 12.889 12.861 -0.2%
CUDA masking_ops_tight_loop 1.565 1.554 -0.7%
CUDA broadcast_binary_add 0.7242 0.7241 -0.0%
CUDA sort_middle_axis_64x1024x64 0.6643 0.6643 -0.0%
  • Vulkan small dispatch went from +2.5% at r6 to +1.7%.
  • CUDA deferred alias copy. I should have raised this one at r6: it was +2.5% there and I left it out of the table. Here it is consistently slower, by about 0.7 µs per iteration.

One cause I can point to

internal::backend_ops() now reads the thread_local std::array activated cache on every call. liblfs_core.so is built with -fPIC, so that read is a __tls_get_addr@plt call each time:

lea    0x90cc7a(%rip),%rdi        # <$tlsld>
call   __tls_get_addr$plt
lea    0x190(%rax),%rbp
mov    0x0(%rbp,%rbx,8),%rax

On dev, Vulkan returned a function-local static and never touched TLS; only CUDA used a thread_local. That fits the pattern: Vulkan got slower and CUDA did not.

As an experiment, I marked the cache [[gnu::tls_model("initial-exec")]]. The lookup became a single %fs: load, and Vulkan went from +1.7% to +1.3% (head still slower in 12 of 15 pairs). So TLS is part of it, but about two thirds of the gap comes from somewhere else in the driver path.

What I'd suggest

  • The TLS lookup. One way is a process-wide cache for drivers whose activation is not per thread, with a per-thread cache only where the driver asks for one. That would be a driver capability, not a backend check in shared code. Another is an initial-exec cache, though that is GCC/Clang only and uses the static TLS reserve when the Python module loads the library.
  • The remaining Vulkan cost and the deferred alias case. Please compare the per-op path against dev for extra virtual calls or atomics between the facade and the native dispatch.

Push a candidate and I'll run the same comparison again.

Requesting changes for these two performance gaps only. Everything else is ready.

@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Follow-up to the latest performance review, in commit 10b22f3.

The candidate addresses the process/TLS lookup and deferred-alias overhead, and includes a small additional Vulkan CPU-side optimization. The comparisons below rerun dev merge base eecd2cc18 alongside the candidate; they do not use an intermediate PR revision as the baseline. The native Linux NVIDIA GPU comparison remains to be verified.

Activation cache and deferred aliases

  • Drivers now declare an immutable activation scope. Vulkan, Metal and HIP use a process-scoped cache; CUDA retains thread-scoped activation so first use on a worker still performs native device binding. Shared dispatch does not identify backend names to choose this policy.
  • Process-scoped pointers are published with release/acquire ordering. Cache hits do not consult a capability, activate a driver or access TLS. The TLS/miss path is a separate local, non-inlined helper. Inspection of the GCC PIC output confirms that the process-cache hit no longer calls __tls_get_addr; MSVC also keeps the hit path free of the miss path's stack setup. No initial-exec TLS model or static TLS reserve dependency was added.
  • Cache invalidation clears process slots and the calling thread's slots. The operation objects retain process lifetime; native context shutdown/recreation and worker binding remain the adapters' responsibility. Availability is not cached as a consequence of activation.
  • Deferred materialization skips implicit native binding when the tensor has no home target, and alias publication skips native canonicalization of a null home. set_stream(nullptr) uses the same invariant. Backend validation, publication locking, non-null target handling and rehoming remain in place.

Additional reductions of repeated work

The cache change alone did not remove the entire Vulkan gap, so the per-operation path was examined further:

  • Fresh allocation metadata already has an empty external label and owner. Adoption now populates those fields only when the native allocator supplies them; arena-backed/external ownership is still retained.
  • Storage adoption moves its remaining temporary shared-owner reference into the metadata alias instead of incrementing and immediately decrementing the reference count. Data and metadata still share the same allocation owner; this removes one temporary pair of atomic reference-count operations without changing storage layout or lifetime.
  • Vulkan pipeline specialization keys: the existing path formatted every specialization constant with std::format on each lookup. It now appends the same colon-separated decimal values using std::to_chars. Ten characters cover every uint32_t. This reduces a pre-existing CPU cost rather than changing the shader or introducing another cache. Key identity, locking, lookup, pipeline creation and pipeline lifetime are unchanged.

No kernel mathematics, shader code, random-number algorithm or trainer behavior was changed by this follow-up. CPU affinity is used only in one benchmark control; the application has no new affinity setting or forced scheduling policy.

Performance against dev

Windows: RTX 5060 Ti, driver 617.42, optimized Release (/O2 /Ob2 /DNDEBUG), fastcompile disabled on both sides. NVIDIA was selected explicitly for Vulkan. Each benchmark process loaded the corresponding baseline or candidate core DLL.

For each case and mode, two series of four alternating ABBA/BAAB blocks give 16 processes per side, with 1,001 timed samples per process. Each process reports its median. Table times are geometric means of those process medians; percentages are geometric means of the paired block ratios. All blocks, including unfavorable results and outliers, are retained. The baseline was rerun during each comparison. No local compilation ran alongside the Windows measurements.

Windows, normal scheduling — no forced affinity:

Case dev ms Candidate ms Time change Candidate slower blocks
Vulkan dispatch_overhead_small 0.727706 0.690867 -5.06% 0/8
CUDA deferred_alias_copy_materialize 0.016664 0.016582 -0.49% 3/8

The Vulkan series were -5.33% and -4.79%; the alias series were -0.90% and -0.08%.

Windows scheduling control — benchmark main thread fixed to logical CPU 2 on both sides:

Case dev ms Candidate ms Time change Candidate slower blocks
Vulkan dispatch_overhead_small 0.739244 0.690224 -6.63% 0/8
CUDA deferred_alias_copy_materialize 0.016602 0.016595 -0.04% 3/8
CUDA dispatch_overhead_small 3.328227 3.328431 +0.006% 5/8

The Vulkan series were -8.27% and -4.96%. Alias results were -0.16% and +0.08%. CUDA dispatch varied between -3.31% and +3.44% across the two series. The CUDA aggregate results are consistent with parity in these measurements, not evidence of a meaningful speedup.

macOS, the same dev/candidate comparison and repetition scheme:

Case dev ms Candidate ms Time change Candidate slower blocks
Vulkan dispatch_overhead_small 2.535264 2.526518 -0.34% 1/8
Metal dispatch_overhead_small 0.613865 0.620936 +1.15% 5/8

Mac Vulkan stayed close to dev in both series (-0.43% / -0.26%). Metal was much more variable: series +4.03% / -1.65%, with individual blocks ranging from -16.10% to +19.59%. These results do not establish a Metal speedup or an absolute parity guarantee.

The Windows Vulkan improvement repeated in all eight blocks under both scheduling modes. These are small-operation microbenchmarks, not full-training measurements, and they do not establish native Linux NVIDIA performance.

Regression and additional checks

Six new parameterized contracts cover:

  • views retaining storage after the original owner is destroyed and another allocation is created;
  • moved allocations, release of aliases, and independent clones;
  • null-home canonicalization and implicit-binding invariants;
  • activation from four workers after cache reset, with usable adapters and exception-safe worker startup;
  • deferred aliases publishing the same storage and values with no home target;
  • deferred aliases retaining their producer home after the producer's queue scope has ended.

Final validation:

Configuration Result
Windows CUDA + Vulkan 7/7 CTest groups
Windows HIP without CUDA 10/10, including Python bindings and recovery
macOS Metal + Vulkan 7/7, plus backend exit contract
Linux under WSL/GCC Build passed; 3/3 CPU/runner/Python groups, plus exit contract

The Linux checks exercise compilation and CPU contracts; they are not GPU performance validation. GPU-specific cases skipped in CPU runs are not counted as evidence of GPU correctness.

Additional local diagnostics and checks:

  • A phase-separated dispatch probe identified the residual Vulkan cost primarily in CPU preparation while the final GPU wait remained similar. Allocation-only probes showed that improving ownership alone did not recover the whole dispatch gap. These diagnostics guided the pipeline-key change; the performance tables above use the original benchmark workloads.
  • 500,014 decimal-key equivalence checks, including zero, decimal-width boundaries, the maximum uint32_t and deterministic random inputs, matched the previous formatting exactly.
  • GCC PIC and MSVC assembly inspection of the activation-cache hit path.
  • Clang-format, git diff --check, error-debt baseline, backend neutrality, I/O discipline and CUDA log-format checks all passed.

The commit contains production code, regression tests and public contract documentation. Benchmark probes, raw measurements and development reports remain outside the repository changes. This candidate keeps the review's dev baseline unchanged; the native Linux NVIDIA comparison is the remaining performance verification.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the process-scoped activation cache works. Vulkan small dispatch is now faster than dev.

I rebuilt 10b22f3 on Linux (CUDA 13.3 + Vulkan, Release, RTX 4080).

Tests

  • Contracts 5/5, tensor_backend_shutdown_contracts and the 71 backend gtests pass.

Performance against dev

Same setup as last round: dev merge base eecd2cc vs head, 15 alternating base/head process pairs, machine otherwise idle. Medians in ms:

Case dev 10b22f3 Change Head slower
Vulkan dispatch_overhead_small (gui bench) 0.5723 0.5119 -10.5% 0 of 15 pairs
CUDA deferred_alias_copy_materialize 0.01173 0.01214 +3.5% 14 of 15 pairs
CUDA dispatch_overhead_small (gui bench) 0.8808 0.8771 -0.4% 4 of 15
CUDA eager_binary_dispatch_tight_loop 13.005 12.914 -0.7% 1 of 15
CUDA masking_ops_tight_loop 1.600 1.588 -0.8% 4 of 15
CUDA broadcast_binary_add 0.7291 0.7294 +0.0% 10 of 15
CUDA sort_middle_axis_64x1024x64 0.6641 0.6642 +0.0% 8 of 15

What is left

  • Vulkan: the gap from r7 (+1.7%) is gone.
  • CUDA deferred alias copy: it is still slower, about 0.4 µs per iteration (+6.4% at r7). Head is slower in 14 of 15 pairs, so this is not noise.

The case runs three lazy elementwise ops, one materialization, then data_ptr() on four aliases of the result (bench_tensor_launch_overhead.cpp:152). Every CUDA case that only dispatches is now at or below dev. So the remaining cost sits in the deferred path, Tensor::materialize_deferred_slow and the alias data_ptr() that follows it, not in dispatch. Please compare that path against dev for work that is new per materialization. Candidates are the TensorTargetBinding for the home stream, canonical_home_target, validate_materialized and the storage adoption.

Push a candidate and I'll run the same comparison again.

Requesting changes for this one gap only. Everything else is ready.

@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Pushed candidate 73a7125 for the remaining CUDA deferred_alias_copy_materialize gap.

Scoped change

Tensor::materialize_deferred_slow() now moves data_owner_ and storage_meta_ from its local published snapshot into the destination handle. Previously those references were copied, only to be decremented when the temporary was destroyed. The shared lazy->result retains its own ownership for siblings that have not materialized yet.

This removes two redundant reference-count increments and their matching temporary decrements per publication. GCC disassembly confirms the two increments disappear from this path. Validation, gate locking, stream ordering and backend checks are unchanged. Dispatch, activation caching, Vulkan-specific code, kernels and trainers are unchanged by this commit.

For the exact benchmark on Windows, the source, deferred result, materialized result and current CUDA stream were all null, so the guarded home-target binding and canonicalization calls were not executed. validate_materialized is already unchanged from dev. I therefore kept those checks and target-handling contracts intact. This is a narrowly scoped reduction in deferred-publication work, not a claim that the removed reference copies were newly introduced by this PR.

The added backend-parameterized regression test destroys the first materialized handle while a sibling remains deferred, then verifies the sibling's storage, values and placement. It also checks that a view remains usable after that sibling is released.

Validation

  • Windows CUDA/Vulkan: 7/7 CTest groups passed.
  • Windows HIP without CUDA: 10/10 groups passed.
  • macOS Metal/Vulkan: 7/7 groups plus backend exit contracts passed.
  • WSL CUDA/CPU: 4/4 groups passed on both dev and the candidate, including the CUDA-required runner.
  • Clang-format, diff checks, error debt, backend neutrality, I/O discipline and CUDA log-format checks passed locally.

The other-backend runs were regression checks of the shared materialization helper; there is no additional HIP or other backend optimization in this commit.

CUDA measurements against dev

The reference remains eecd2cc18, rebuilt and rerun, rather than an intermediate PR revision. Both WSL builds used Release, GCC 14, CUDA 12.8, sm_120, an RTX 5060 Ti and driver 617.42. Compilation finished before timing began. The original deferred-alias workload was used with 1,001 samples per process and no application affinity changes.

Times below are geometric means of process medians; changes use paired geometric ratios.

Environment / series dev (ms) Candidate (ms) Change Candidate slower
Windows, two series of four ABBA/BAAB blocks 0.016397 0.016428 +0.19% 5/8 blocks
WSL, series 1: 15 alternating process pairs 0.026705 0.026018 -2.57% 5/15 pairs
WSL, series 2: 15 alternating process pairs 0.028957 0.027777 -4.08% 5/15 pairs

Across the 30 WSL pairs, the aggregate change is -3.33%, but the median paired change is only -0.70% and individual pairs range from approximately -35.7% to +25.1%. Windows is effectively unchanged at the precision of these measurements.

CUDA ran successfully under WSL, providing additional functional and GCC coverage. However, the timing variability is too large to establish that the approximately 0.4 microsecond native-Linux gap is closed, or to attribute the aggregate WSL improvement to this change alone. These results do not invalidate the review's consistent regression measurement on the RTX 4080 with CUDA 13.3. Parity on that native-Linux setup remains to be verified.

Only the implementation and regression test are committed; benchmark experiments and raw reports remain local.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Built 73a7125 and ran the same comparison as last round (dev merge base eecd2cc vs head, 15 alternating base/head process pairs, idle machine, RTX 4080). Contracts 5/5, tensor_backend_shutdown_contracts and the 71 backend gtests pass. Vulkan small dispatch stays fixed at -10.3%, 0 of 15 pairs slower.

One thing is still open, the same one as before:

  1. CUDA deferred_alias_copy_materialize is 0.01149 -> 0.01180 ms (+2.7%), head slower in 15 of 15 pairs. The move of data_owner_/storage_meta_ helped a bit (it was +3.5% at r8), but the gap is still there. Every dispatch-only CUDA case is within noise (dispatch_overhead_small +0.7%, eager_binary +0.6%, masking -0.1%, broadcast -0.0%, sort +0.1%, each 8-11 of 15 pairs), so the cost is per materialization.

    Diffing Tensor::materialize_deferred_slow against dev leaves two changes on that path besides the moves: the home-stream binding is now built for every GPU tensor with a stream (dev only did it for CUDA when no current stream was set and the stream wasn't retired), and every CUDA stream now goes through backend_driver(backend).canonical_home_target(...), where dev left CUDA streams untouched. Both are a driver lookup plus a virtual call per materialization. Could you keep dev's early-outs there, or give CUDA's identity case a fast path? If neither moves it, the remaining suspects are validate_materialized and the alias data_ptr() path after it.

Push a candidate and I'll rerun it.

@francescofugazzi

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining deferred-materialization paths in dd2a575.

The CUDA stream policy now preserves the original early-outs: an explicit current stream takes precedence, and a retired home is not bound. CUDA home canonicalization is an identity fast path. When an implicit CUDA binding is actually needed, saving, setting and restoring the current stream also use the native CUDA operations directly, avoiding the registry lookup and virtual binding/restoration calls. These compatibility paths stay inside the private tensor backend layer; other backends retain their existing driver policy.

I also reduced the publication work paid by deferred aliases:

  • Production and publication retain the same mutex instead of releasing and reacquiring it for each handle.
  • The local lazy-state owner and held mutex pin the cached result through publication, so there is no temporary Tensor snapshot, temporary identity, or additional snapshot ownership bookkeeping.
  • Each handle shares ownership directly from the cached result. The cache retains its ownership for siblings that have not materialized yet.

The materialized-result validity/deferred checks, backend identity assertion, stream retirement checks and raw-pointer validation remain enabled. There are no changes to GPU kernels, trainers, dispatch activation caches, Vulkan pipeline keys, public API or driver vtables.

Validation

Added regression coverage for explicit-current CUDA stream precedence, retired CUDA homes, binding/restoration after a materializer exception and successful retry, sibling retry after failure, and concurrent alias publication with exactly one expression execution. Existing alias lifetime and queue coverage remains enabled.

Configuration Result
Windows Release, CPU/CUDA/Vulkan operation, runner and queue groups 7/7 passed
Windows Release without CUDA, CPU/HIP operation, runner, Python bindings, recovery and queue groups 10/10 passed
WSL Ubuntu, GCC/CUDA CPU/CUDA operation and runner groups 4/4 passed
Backend shutdown executables, both Windows configurations Passed
clang-format, whitespace, error-debt baseline, backend neutrality, I/O discipline and CUDA log-format checks Passed

Comparison with the requested dev baseline

Baseline remains eecd2cc, rerun alongside the candidate. These are comparisons against dev, not against an intermediate PR revision. The probes were rebuilt against each side's actual headers, and performance runs were sequential after compilation and functional tests finished.

Complete deferred_alias_copy_materialize, geometric means of process medians:

Platform Dev dd2a575 Change Repetition
Windows CUDA 16.65771 us 16.72065 us +0.38% 16 processes per side, 8 ABBA/BAAB blocks; candidate slower in 4/8 blocks
WSL CUDA 25.06923 us 24.90538 us -0.65% 15 alternating pairs; candidate slower in 7/15 pairs

The Windows median block change is +0.006%. Both available environments are close to parity with mixed pair/block results; I would not interpret these small aggregate differences as a reliable complete-workload speedup.

To isolate publication cost, a separate diagnostic materializes and synchronizes the producer before timing const data_ptr() on 64 still-deferred aliases. Allocation, GPU work, synchronization and destruction are outside that timed interval. This is a host-path diagnostic, not a replacement for the complete benchmark:

Platform Dev dd2a575 Change
Windows CUDA 0.15781 us/alias 0.11328 us/alias -28.22%
WSL CUDA 0.13540 us/alias 0.09373 us/alias -30.78%

Each diagnostic uses 8 processes per side in ABBA/BAAB order and 1001 retained samples per process. An additional complete-workload check with a non-null CUDA home was also at parity with dev (-0.068% aggregate, slower in 3/8 blocks).

I investigated the other suggested suspects as well. validate_materialized retains the same checks as dev. Avoiding the label copy and reordering the shared deferred predicate did not produce a reliable complete-workload improvement, so those trials are excluded from this commit; tensor_impl.hpp remains unchanged.

The native Linux/RTX 4080 comparison remains to be verified. The earlier consistent +2.7% measurement was on 73a7125, before these local changes; Windows and WSL results do not establish that the native result is resolved. This candidate removes the identified CUDA stream-policy indirections and measurably reduces alias publication work while preserving the validation contracts.

@MrNeRF MrNeRF left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Built dd2a575 and reran the same 15-pair comparison against the base snapshot.

  • Contracts 5/5, the exit runner and the 71 tensor tests pass.
  • Vulkan dispatch_overhead_small: −10.5%, faster in 15/15 pairs.
  • CUDA deferred_alias_copy_materialize: still +2.86%, slower in 13/15 pairs (r9 was +2.72%, 15/15). The other CUDA cases are within noise.
  1. dd2a575 does not move the number, so please revert it. It also puts #if LFS_HAS_CUDA and backend == GpuBackend::CUDA branches into the shared materializer path, and we want backends to go through the driver interface without special cases in shared code. The CUDA fast path was my suggestion last round; that was the wrong direction.
  2. With the gate held across both phases and published taken by reference, materialize_deferred_slow now does less work than dev on the alias path. So the remaining ~0.3 µs per run is somewhere else in that benchmark: building the lazy add/mul/sub expression, planning it, or the fused launch. Timing those three phases separately on base and head should show which one. dispatch_overhead_small and the eager loop are flat, which points at the lazy-node or planner side rather than the launch.
  3. dev moved today (master sync). Merging it gives one conflict, in tests/test_collision_jobs.cpp.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dev branch Applies to the dev branch only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants