TensorRT inferior scalability on A10, L4 possibly due to locking issues

Description

We run a processing pipeline with multiple ONNX networks on GPUs. Generally, a single pipeline is not enough to saturate a GPU, so we spread processing into multiple threads, each running an independent pipeline – using the same ICudaEngine objects (to reduce VRAM consumption), but different IExecutionContext objects and CUDA streams.

We have noticed that throughput is not scaled well enough with increased number of threads on Nvidia A10 and L4 cards, while T4 is not affected for some reason. All GPUs are installed in a single server, so there are no differences in the environment.

Results for A10:

Threads Throughput (ops/sec)
1 304
2 458
3 520
4 360
5 239
6 274
7 127
8 106

While for T4:

Threads Throughput (ops/sec)
1 275
2 475
3 556
4 604
5 634
6 655
7 665
8 675

There is no point in going overboard with the number of threads, but T4 throughput is stable and grows monotonically, while for A10 it falls off a cliff after 3 threads. Overall, T4 gives noticeably larger max throughput compared to A10 despite being rated twice as slow in terms of FP16 TFLOPS. This is quite disappointing.

Nsight Systems profiler shows an interesting picture. It looks like A10 drowns in locks (mostly calls to pthread_rwlock_wrlock), while for T4 locking is barely noticeable.

Is it a known problem? What can be done to improve scalability on A10 and L4?

Environment

TensorRT Version: v10.12.0.36
GPU Type: Nvidia T4, Nvidia L4, Nvidia A10
Nvidia Driver Version: 595.71.05
CUDA Version: v12.9
Operating System + Version: Ubuntu 22.04

Hello @athkumar !

The issue is probably related/similar to the other topic here:

So, I want to comment on your recommendations from the mentioned discussion:

  • CUDA_MODULE_LOADING=EAGER makes no difference in our case.
  • There are no CUDA allocations in the hot path after the first run (at least in our code).
  • Warm-up is properly done, the issue persists after a huge number of iterations.
  • No multiple processes. There is a single process with multiple threads and no other users of the GPU.

Hi @sergeev917, thanks for the detailed numbers and the Nsight screenshots. This is a useful follow-up to the earlier pthread_rwlock_rdlock thread, and your extra checks rule out the first things I would normally chase: lazy module loading, CUDA allocations in the hot path, cold warmup/JIT, and cross-process contention.

Given that CUDA_MODULE_LOADING=EAGER did not move the result, I would stop treating this as the same exact lazy-loading case. What stands out now is host-side enqueue / driver serialization. A10 and L4 can be faster on paper while still losing if many host threads are entering CUDA/TensorRT enqueue paths at the same time and the kernels are short enough that launch/lock overhead dominates. T4 may look better simply because the GPU work is longer and hides more of that CPU-side cost.

I would split the debug into three focused tests.

1. Compare many submission threads vs one submission thread

Keep the same number of IExecutionContext objects and CUDA streams, but try one version where a single host thread owns submission to all streams through a queue. The goal is not the final architecture yet; it is to test whether the cliff after 3 threads is caused by many CPU threads contending inside the driver.

Profile both versions with the same workload:

nsys profile \
  --trace=cuda,osrt,nvtx \
  --sample=none \
  -o a10_4pipelines_current_threads \
  ./your_app --pipelines=4

nsys profile \
  --trace=cuda,osrt,nvtx \
  --sample=none \
  -o a10_4pipelines_single_submitter \
  ./your_app --pipelines=4 --single-submission-thread

If the single-submitter version recovers A10/L4 scaling, the fix is likely on the scheduling side: fewer host threads calling CUDA APIs, more work queued onto streams, and less lock convoying through the driver.

2. Try CUDA Graph replay for the steady-state path

If your per-pipeline shapes and bindings are stable after warmup, capture the steady-state sequence once per context/stream and replay it. That reduces per-iteration launch/enqueue work, which is exactly the path showing up in the pthread_rwlock_wrlock region.

This is the first thing I would try if the model sequence is fixed enough for graph capture. If shapes vary every iteration, this may not be practical.

3. Separate engine sharing from driver locking

As a diagnostic only, build a version with duplicate ICudaEngine objects per worker on A10/L4 and keep the rest of the workload the same. I understand why you share engines to save VRAM, but this test answers a different question:

  • If duplicate engines scale better, some shared engine/runtime state is part of the contention.
  • If duplicate engines do not change the result, the bottleneck is lower in the CUDA driver / context path.

One targeted data point that would help: can you run the A10 4-pipeline case in the current many-thread mode and in the single-submission-thread mode, then share the throughput plus the Nsight Systems pthread_rwlock_* rows for both? That should tell us whether to focus on TensorRT object sharing or driver-level enqueue serialization.

Thanks,
Atharva

Hi @athkumar!

I think it is unlikely to be the case here. The benchmark in the first post shows marginal improvements on A10 compared to T4 with a single thread generating the load (that is, without possible locking issues) – from 275 to 304 RPS (+10%). It seems that this is just too small of a change to result in the observed issues.

Though, if TensorRT generated a different plan with noticeably larger number of kernels for A10, it would definitely make more sense.

This idea came to mind before, but there were no meaningful changes in testing.

I will look into other recommendations and tests this week.

Hi @athkumar!

I’ve run multiple tests and optimizations – like adding intermediate page-locked buffers for data transfers, disabling implicit sync with the default stream, using CUDA Graphs and also some ONNX-level changes. All of them yielded benefits, but CUDA graphs were the most valuable by far.

CUDA Graphs improve throughput drastically for A10. And a little bit for T4 as well.

T4:

Threads RPS (no graphs) RPS (with graphs)
1 268 250
2 404 472
3 427 614
4 651 723
5 694 785
6 716 813
7 749 840
8 759 858

A10:

Threads RPS (no graphs) RPS (with graphs)
1 244 409
2 308 624
3 480 815
4 408 1,051
5 292 1,196
6 514 1,298
7 306 1,344
8 220 1,408

CUDA Graphs solve the immediate problem, but we also have object detection algorithms which work with dynamic input shapes – making CUDA Graphs less applicable. So, I would like to continue with additional testing – the pipeline is the same as before, but we ignore CUDA Graph optimization.

Benchmarking results for our pipeline are more noisy than I would prefer, so I used 8 threads vs 8 streams – the issue is more pronounced this way. Also, we have data dependencies across inferences, which require data transfer and sync. Because of that, the single-submission-thread mode is implemented in the following way: jobs for the first pipeline step are enqueued into all 8 streams, then all streams are synced, the second step jobs are enqueued into all 8 streams, etc.

The single-submission-thread mode shows (visually) less locking in Nsight, but the resulting throughput is similar (if not worse) compared to the many-threads mode. The many-threads mode gives distribution ranging from 200 to 425 RPS. The single-submission-thread mode – from 26 to 44 RPS (but each one is a batch of 8 – thus, the final result is a range from 208 to 352 RPS). Note that a single thread bears all pre and post-processing from all streams. Probably, a more synthetic benchmark is needed for a proper evaluation.

A10, 8 threads:

A10, 1 thread with 8 streams:

For completeness – here are the results for T4, 8 threads:

T4, 1 thread with 8 streams:

Benchmarks for T4: many threads – 762-778 RPS, the single submission thread – 24-41 RPS (ie 192 - 328 RPS). I would like to note here that the many-threads mode not only gives the superior results (T4 vs A10), but these results are much more stable as well.

@athkumar Hello! Are there any news on the subject?

TensorRT Multi-threaded Scalability Degradation on A10/L4 GPUs

Observed Failure:
User reports severe throughput degradation when scaling TensorRT inference across multiple threads on NVIDIA A10 and L4 GPUs, while T4 GPUs scale normally. On A10, throughput peaks at 3 threads (520 ops/sec) then collapses to 239 ops/sec at 5 threads and 106 ops/sec at 8 threads. T4 shows monotonic scaling from 275 ops/sec (1 thread) to 675 ops/sec (8 threads).

Root Cause Hypothesis:
Nsight Systems profiler evidence shows A10 is dominated by pthread_rwlock_wrlock contention in the CUDA driver, while T4 exhibits minimal locking overhead. NVIDIA moderator Athkumar identified the likely cause as host-side enqueue/driver serialization, suggesting hardware-specific differences in how A10/L4 handle concurrent kernel submissions from multiple threads.

Environment:

  • TensorRT: v10.12.0.36
  • CUDA Driver: 595.71.05
  • CUDA Toolkit: 12.9
  • GPUs: NVIDIA A10, L4, T4 (in same server)
  • OS: Ubuntu 22.04
  • Workload: Multi-threaded ONNX inference pipeline using shared ICudaEngine, separate IExecutionContext and CUDA streams per thread

Reproduction:
User provided detailed throughput benchmarks and Nsight Systems profiler traces showing lock contention patterns. Benchmarks are reproducible and consistent across multiple test runs.

Workaround Status:
User tested CUDA Graphs as suggested by moderator: A10 throughput improved from 408→1,051 ops/sec at 4 threads; T4 improved from 651→723 ops/sec. However, CUDA Graphs are not applicable for dynamic-shape object detection workloads, leaving the user without a viable solution for their production use case.

Impact:
A10/L4 GPUs are effectively unusable for multi-threaded TensorRT inference at scale, despite higher theoretical compute capacity than T4. This blocks adoption of newer GPU hardware for existing multi-threaded inference pipelines.

Suspected Component:
CUDA driver host-side enqueue/kernel submission serialization on A10/L4 architectures.

Sorry for the month of silence, that’s on me. You posted two substantial updates and then had to ping to get an answer, after doing most of the experimental work yourself. Not the turnaround you should be getting.

Two corrections on the summary posted just above mine, since both points would send you the wrong way:

  • A10 is not “effectively unusable” here. Your own Jun 28 table has A10 at 1,408 RPS at 8 threads against T4’s 858. That is the fastest configuration measured anywhere in this thread.
  • CUDA Graphs are not off the table for dynamic shapes. There is a supported per-shape pattern, in section 3 below.

1. The single-thread row is the answer

GPU 1 thread, no graphs 1 thread, with graphs Delta
A10 244 409 +68%
T4 268 250 -7%

At one thread there are no sibling worker threads to convoy against, so lock contention cannot explain that A10 gap. The only thing CUDA Graphs take away at one thread is per-kernel launch cost. A10 gaining 68% from that means your A10 path was spending most of its wall clock launching kernels instead of running them. That is the definition of an enqueue-bound workload: each launch costs the CPU and driver roughly 5 to 15 microseconds, and when kernels are only a few microseconds of GPU work, launching becomes the bottleneck.

T4 losing 7% is the same fact seen from the other side. Its kernels run long enough to hide the launch cost already, so graphs buy nothing and the replay bookkeeping costs a little.

So A10 is not worse at concurrency. It finishes each kernel sooner, which means the same launch rate starves it first. That is also the honest answer to your Jun 22 point: the +10% A10 lead at one thread looked too small to matter, but it was small precisely because A10 was already launch-capped at one thread. Take the launches away and it is 409 against 250, much closer to the roughly 2x FP16 gap you were expecting from the spec sheet. I framed this in June as “T4 hides the CPU cost”, which pointed the right way but explained it badly.

The rest follows. Once you are launch-limited, extra host threads just add more callers to the same driver launch path, and that is where your pthread_rwlock_wrlock band comes from. The locks are a symptom of the launch rate, not a separate A10 defect. It also explains your single-submitter result: you removed the contention, which is why Nsight showed less locking, but the total number of launches never changed and you moved all pre and post-processing onto one thread. Same launch bill, worse CPU distribution.

2. Confirm it per network, one command

Before restructuring anything, get an objective read on which of your networks are actually enqueue-bound:

trtexec --loadEngine=detector.plan --noCudaGraph --shapes=images:1x3x640x640

trtexec enables CUDA graphs by default, so --noCudaGraph gives you the ungraphed path. Compare the reported Enqueue Time against GPU Compute Time. Enqueue close to or above compute means that network is enqueue-bound and section 3 applies. Compute dominating means that network was never your problem.

3. CUDA Graphs with dynamic shapes

The constraint is that a captured graph is pinned to one input size and one context state, not that dynamic shapes are excluded. The documented way around it is one execution context per captured graph, with the contexts sharing device memory through createExecutionContextWithoutDeviceMemory() so N contexts do not cost N times the activation memory.

Build a graph cache keyed on shape:

// once per distinct shape
IExecutionContext* ctx = engine->createExecutionContextWithoutDeviceMemory();
ctx->setDeviceMemoryV2(sharedActivations, sharedActivationsSize);
ctx->setInputShape("images", shape);
ctx->enqueueV3(stream);   // flushes the deferred shape update

cudaGraph_t graph;
cudaGraphExec_t instance;
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
ctx->enqueueV3(stream);
cudaStreamEndCapture(stream, &graph);
cudaGraphInstantiate(&instance, graph, 0);
graphCache[shapeKey] = instance;

Then cudaGraphLaunch on a cache hit, and only a first-seen shape pays capture. That bare enqueueV3() before cudaStreamBeginCapture is required rather than optional: TensorRT defers the shape-change work, and capturing before it is flushed records the wrong thing.

⚠️ The captured graph also records the activation memory address and the input and output buffer addresses. If you pool buffers per stream, every (shape, buffer set) pair needs its own captured graph. Getting this wrong gives you undefined behavior rather than a clean error.

If the detector’s shape space is too wide for a cache, letterbox or pad to a small set of canonical sizes first. Most detection pipelines already resize to a fixed input and only vary batch, and bucketing batch to powers of two gets you down to a handful of graphs.

4. When a network cannot be captured at all

For an enqueue-bound network that cannot be graphed, make each launch do more work: raise the batch per enqueueV3() call so kernel duration grows against that fixed 5 to 15 microsecond launch cost. The docs give this as the direct alternative to graphs for the same problem. Fewer and larger inferences beat more and smaller ones on A10 specifically, because A10 is the card that runs out of work first.

One measurement would settle the rest: the Enqueue Time and GPU Compute Time from that --noCudaGraph run on your dynamic-shape detector. If enqueue dominates, the graph cache is worth the bookkeeping. If compute dominates, that network was never the bottleneck and we should look at the pipeline around it instead.

I will put a synthetic dynamic-shape detector with a per-shape graph cache on an A10 here and post the numbers in this thread within the next week, so you have something to compare against rather than taking the pattern on faith.

Best, Atharva

Hi @athkumar!

While I agree that the lock contention cannot explain the difference for a single thread run, I do not think that the story is as simple.

Let’s assume that A10 is simply faster than T4 and everything else is equal. For both A10 and T4 we would have to schedule operations into a CUDA stream and GPU would have to execute the scheduled operations. Our expectation would be that the scheduling cost is the similar (per op) and A10 would run operations faster — meaning A10 RPS should be larger. But this is not generally true — A10 loses in a single thread run if CUDA graphs are disabled. Yes, A10 being faster means more scheduled operations with own overhead, but it should reach at least the same RPS. Unless the scheduling overhead cost non-linearly blows up so spectacularly that it eats all A10 gains against T4 + more.

At this point I can only assume that Ampere and newer architectures are designed in a way that require more complex scheduling compared to Turing. So, CUDA graphs are necessary now to cut scheduling costs and unlock the hardware potential. It would be interesting to hear any insights on this topic.