<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Paramita Choudhury</title>
    <description>The latest articles on DEV Community by Paramita Choudhury (@choupara).</description>
    <link>https://dev.to/choupara</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4007245%2F6a7430db-5a0b-4b99-bb95-2f6987f78eab.png</url>
      <title>DEV Community: Paramita Choudhury</title>
      <link>https://dev.to/choupara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/choupara"/>
    <language>en</language>
    <item>
      <title>Debugging Score-P with PyTorch DDP: A Field Guide to CUDA Error 802 and Other Surprises</title>
      <dc:creator>Paramita Choudhury</dc:creator>
      <pubDate>Mon, 29 Jun 2026 05:17:28 +0000</pubDate>
      <link>https://dev.to/choupara/debugging-score-p-with-pytorch-ddp-a-field-guide-to-cuda-error-802-and-other-surprises-4ehe</link>
      <guid>https://dev.to/choupara/debugging-score-p-with-pytorch-ddp-a-field-guide-to-cuda-error-802-and-other-surprises-4ehe</guid>
      <description>&lt;p&gt;When I set out to instrument my multi-GPU DNABERT-2 training runs with &lt;strong&gt;Score-P&lt;/strong&gt; to analyse DDP communication overhead, I expected the hard part to be understanding the traces. Instead, the hard part turned out to be getting Score-P to coexist with PyTorch's &lt;code&gt;torchrun&lt;/code&gt;-based DDP launch mechanism at all.&lt;/p&gt;

&lt;p&gt;This post documents every error I hit and exactly how I fixed each one - in the hope that the next person trying to trace a PyTorch DDP workload with Score-P doesn't spend two days rediscovering the same root causes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup:&lt;/strong&gt; DNABERT-2 (117M-parameter genomic transformer), PyTorch 2.1.2, Score-P 8.1 with Python bindings, a SLURM cluster with A100-SXM4-40GB GPUs, 1/4/8-GPU configurations.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;A companion post — &lt;a href="https://choupara.github.io/posts/2026/06/where-time-goes/" rel="noopener noreferrer"&gt;Where does the time really go in multi-GPU training?&lt;/a&gt; — covers what the traces actually revealed once they worked. This post is purely the war stories of getting there.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Background: two things Score-P does that fight PyTorch DDP
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The re-exec mechanism.&lt;/strong&gt; When you run &lt;code&gt;python -m scorep train.py&lt;/code&gt;, Score-P does not simply import itself and start tracing. It sets environment variables (including &lt;code&gt;LD_PRELOAD&lt;/code&gt;) to load its C measurement library, then &lt;em&gt;re-executes the entire Python process&lt;/em&gt; from scratch with those variables in place. Your script effectively starts twice: once as the launcher, once as the instrumented process. Anything that happens before the re-exec - including CUDA initialisation - happens in a process that then exits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two-layer CUDA model.&lt;/strong&gt; CUDA has two separate APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;em&gt;driver API&lt;/em&gt;, used by &lt;code&gt;nvidia-smi&lt;/code&gt;, &lt;code&gt;nvmlInit()&lt;/code&gt;, etc. - what the kernel module exposes.&lt;/li&gt;
&lt;li&gt;The &lt;em&gt;runtime API&lt;/em&gt;, used by &lt;code&gt;cudaGetDeviceCount()&lt;/code&gt;, &lt;code&gt;torch.cuda.is_available()&lt;/code&gt;, &lt;code&gt;torch.zeros(1).cuda()&lt;/code&gt; - what PyTorch and Score-P's CUDA adapter both use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a freshly allocated SLURM job, the driver API can respond immediately while the runtime API is still initialising - sometimes for tens of seconds. Score-P's C CUDA adapter, loaded via &lt;code&gt;LD_PRELOAD&lt;/code&gt; at library-load time, probes the runtime API the moment the process starts. If the runtime isn't ready yet, the probe poisons the CUDA context for that process permanently.&lt;/p&gt;

&lt;p&gt;With that context, here are the errors.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 1: FP16 ValueError - a hidden CUDA Error 802
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Training crashed immediately with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: FP16 Mixed precision training with AMP or APEX ('--fp16') can only
be used on CUDA devices.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looked like a config error - I was clearly on a GPU node, &lt;code&gt;nvidia-smi&lt;/code&gt; showed four A100s, yet PyTorch claimed no CUDA device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real cause.&lt;/strong&gt; Score-P's C CUDA adapter was loaded via &lt;code&gt;LD_PRELOAD&lt;/code&gt; at startup, before PyTorch initialised CUDA. The adapter called &lt;code&gt;cudaGetDeviceCount()&lt;/code&gt; while the runtime was still in the &lt;code&gt;cudaErrorSystemNotReady&lt;/code&gt; (Error 802) state. That left the CUDA context permanently broken for the process; the later &lt;code&gt;torch.cuda.is_available()&lt;/code&gt; returned &lt;code&gt;False&lt;/code&gt;, and the FP16 ValueError was just a downstream symptom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix direction.&lt;/strong&gt; The CUDA runtime must be warmed up - forced to fully initialise - &lt;em&gt;before&lt;/em&gt; &lt;code&gt;python -m scorep&lt;/code&gt; sets &lt;code&gt;LD_PRELOAD&lt;/code&gt;. Once Score-P's C library is loaded, it's too late.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 2: the nvidia-smi check was the wrong layer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First attempt.&lt;/strong&gt; A pre-flight check that polled &lt;code&gt;nvidia-smi&lt;/code&gt; until it responded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if &lt;/span&gt;nvidia-smi &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ngpus_visible&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ngpus_expected&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CUDA ready after &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt; attempt(s)."&lt;/span&gt;
        &lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;3
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it wasn't enough.&lt;/strong&gt; &lt;code&gt;nvidia-smi&lt;/code&gt; uses the &lt;em&gt;driver&lt;/em&gt; API. A successful call only proves the kernel module is responding - it says nothing about whether &lt;code&gt;cudaGetDeviceCount()&lt;/code&gt; would succeed. On a fresh job, &lt;code&gt;nvidia-smi&lt;/code&gt; passes on attempt 1 while the runtime API is still &lt;code&gt;cudaErrorSystemNotReady&lt;/code&gt;. Wrong layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 3: &lt;code&gt;assert torch.cuda.is_available()&lt;/code&gt; fails immediately
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Second attempt.&lt;/strong&gt; A Python check inside the warmup loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import torch; assert torch.cuda.is_available(); torch.zeros(1).cuda()"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it failed.&lt;/strong&gt; On a cold node, &lt;code&gt;torch.cuda.is_available()&lt;/code&gt; can return &lt;code&gt;False&lt;/code&gt; &lt;em&gt;without&lt;/em&gt; raising - it just returns False silently. The &lt;code&gt;assert&lt;/code&gt; then exits on the very first attempt, before the runtime had time to initialise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Drop the &lt;code&gt;assert&lt;/code&gt;. Call &lt;code&gt;torch.zeros(1).cuda()&lt;/code&gt; directly inside &lt;code&gt;try/except&lt;/code&gt; - let the exception be the "not ready yet" signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 30&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if &lt;/span&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import torch, sys
try:
    torch.zeros(1).cuda()
except Exception:
    sys.exit(1)
"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CUDA runtime ready after &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt; attempt(s)."&lt;/span&gt;
        &lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CUDA runtime not ready (attempt &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;/30), sleeping 10s..."&lt;/span&gt;
    &lt;span class="nb"&gt;sleep &lt;/span&gt;10
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This warmup runs &lt;em&gt;without&lt;/em&gt; Score-P active - no &lt;code&gt;LD_PRELOAD&lt;/code&gt;, no CUDA adapter - so it forces the runtime to initialise once. Every later process (including the Score-P-instrumented workers) then finds the runtime already warm.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 4: a node with a permanently broken CUDA runtime
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Even with the 30-attempt warmup (5 minutes), all attempts failed on one particular node, while &lt;code&gt;nvidia-smi&lt;/code&gt; passed on attempt 1 every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; That node had a broken CUDA runtime install: the driver was fine, but &lt;code&gt;cudaGetDeviceCount()&lt;/code&gt; never returned. A sysadmin problem, not an application one - and the scheduler kept landing my jobs there because it was first in the queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Exclude the bad node in the SLURM script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#SBATCH --exclude=&amp;lt;broken_node&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, jobs landed on healthy nodes where both checks passed on attempt 1.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 5: the &lt;code&gt;scorep.user&lt;/code&gt; import in DDP worker processes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; After fixing the node, 4- and 8-GPU runs still failed with Error 802 - this time in the worker processes spawned by &lt;code&gt;torchrun&lt;/code&gt;, not the main process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; I had imported &lt;code&gt;scorep.user&lt;/code&gt; at module level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;scorep.user&lt;/span&gt;  &lt;span class="c1"&gt;# module-level import
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;torchrun&lt;/code&gt; spawns one worker per GPU, each worker re-imports the module, and &lt;code&gt;import scorep.user&lt;/code&gt; triggers Score-P's CUDA adapter init &lt;em&gt;inside each freshly-spawned subprocess&lt;/em&gt; - before PyTorch sets up that worker's CUDA context. The parent-shell warmup does not carry into child processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Lazy import: defer &lt;code&gt;import scorep.user&lt;/code&gt; until the first &lt;code&gt;training_step()&lt;/code&gt;, by which point PyTorch has initialised CUDA for that worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Module level - not imported yet
&lt;/span&gt;&lt;span class="n"&gt;_scorep_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# None = not yet tried; False = import failed
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScorePTrainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transformers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trainer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;training_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_scorep_user&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_scorep_user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;scorep.user&lt;/span&gt;
                &lt;span class="n"&gt;_scorep_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scorep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;_scorep_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_scorep_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_scorep_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;region_begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dnabert_train_step&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;training_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_scorep_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_scorep_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;region_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dnabert_train_step&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;None&lt;/code&gt; guard means the import is attempted exactly once per process, on the first step - after CUDA is ready for that rank.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 6: Score-P memory limit exceeded
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; With CUDA kernel tracing on (&lt;code&gt;SCOREP_CUDA_ENABLE=kernel,memcpy,sync&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Score-P] Warning: Too many memory requested. Score-P supports only up to,
but not including, 4 GiB of total memory per process. Reducing to its maximum value.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had set &lt;code&gt;SCOREP_TOTAL_MEMORY=4G&lt;/code&gt;. Score-P's hard per-process limit is &lt;em&gt;strictly less than&lt;/em&gt; 4 GiB - exactly 4G hits the ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; &lt;code&gt;SCOREP_TOTAL_MEMORY=3500M&lt;/code&gt; - under the cap, with room for CUDA kernel traces across 8 ranks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 7: &lt;code&gt;load_best_model_at_end&lt;/code&gt; strategy conflict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The short 50-step trace runs failed instantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: --load_best_model_at_end requires the save and eval strategy to match,
but found Evaluation strategy: NO / Save strategy: STEPS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd set &lt;code&gt;--evaluation_strategy no&lt;/code&gt; to keep eval passes from distorting the trace timeline, but &lt;code&gt;load_best_model_at_end=True&lt;/code&gt; requires matching save/eval strategies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; There is no "best model" for a 50-step diagnostic run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--evaluation_strategy&lt;/span&gt; no &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--load_best_model_at_end&lt;/span&gt; False &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--save_steps&lt;/span&gt; 10000 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Error 8: the trace contained the launcher, not the workers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The runs completed, produced an OTF2 trace, and opened cleanly in Vampir - showing a single red bar: &lt;code&gt;...LocalElasticAgent._invoke_run&lt;/code&gt;. No kernels. No NCCL. No training steps. The process filter listed exactly &lt;strong&gt;one&lt;/strong&gt; process.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57w03g8bitfl1tobo1ha.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F57w03g8bitfl1tobo1ha.png" alt="Vampir Master Timeline of the launcher-only trace: a single red  raw `_invoke_run` endraw  bar spanning the entire run, no GPU kernels, one process in the filter" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; The launcher was &lt;code&gt;python -m scorep .../scorep_torchrun.py&lt;/code&gt;, where &lt;code&gt;scorep_torchrun.py&lt;/code&gt; is just &lt;code&gt;from torch.distributed.run import main; main()&lt;/code&gt; - i.e. plain &lt;code&gt;torchrun&lt;/code&gt;. Its elastic agent &lt;strong&gt;spawns the GPU workers as separate child processes&lt;/strong&gt; that start fresh &lt;code&gt;python&lt;/code&gt; interpreters with no Score-P. Score-P therefore instrumented only the agent - the babysitter - which spends the whole run waiting. On disk the proof was unambiguous: the entire 8-GPU trace held a single process's events, and &lt;code&gt;scorep-score&lt;/code&gt; showed only Python (&lt;code&gt;USR&lt;/code&gt;) regions - no &lt;code&gt;CUDA&lt;/code&gt; type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;traces/
&lt;span class="gp"&gt;0.def   0.evt                          #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;one process, not eight
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;scorep-score profile.cubex
&lt;span class="go"&gt;flt  type  max_buf[B]   visits  time[s] time[%]  region
     ALL  16,266,573  625,628   38.47   100.0   ALL
     USR  16,266,302  625,627   38.04    98.9   USR     ← all Python, the agent waiting
  SCOREP        271        1    0.43     1.1   SCOREP
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Stop letting &lt;code&gt;torchrun&lt;/code&gt; spawn. Launch each rank yourself in a background loop, each as its own &lt;code&gt;python -m scorep&lt;/code&gt; process with its own &lt;code&gt;SCOREP_EXPERIMENT_DIRECTORY=scorep_rank_N&lt;/code&gt;, using a single-node rendezvous (&lt;code&gt;MASTER_ADDR=localhost&lt;/code&gt;, per-rank &lt;code&gt;RANK&lt;/code&gt;/&lt;code&gt;LOCAL_RANK&lt;/code&gt;). This is exactly what &lt;code&gt;torchrun&lt;/code&gt; does internally - fork N ranks, hand each its identity - except now every rank runs under Score-P. No &lt;code&gt;srun&lt;/code&gt;, no spawn.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 9: &lt;code&gt;SCOREP_CUDA_ENABLE&lt;/code&gt; captured zero kernels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; With per-rank launch working, the traces contained the training process - but &lt;strong&gt;zero CUDA kernels&lt;/strong&gt;. &lt;code&gt;scorep-score&lt;/code&gt; showed 98% &lt;code&gt;USR&lt;/code&gt; (Python) regions and no &lt;code&gt;CUDA&lt;/code&gt; type at all, despite &lt;code&gt;SCOREP_CUDA_ENABLE=kernel,memcpy,sync&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; The Score-P Python wrapper passes unknown flags to &lt;code&gt;scorep-config&lt;/code&gt;, whose help is explicit: &lt;code&gt;--cuda|--nocuda … On default cuda instrumentation is disabled.&lt;/code&gt; Setting &lt;code&gt;SCOREP_CUDA_ENABLE&lt;/code&gt; only configures &lt;em&gt;what&lt;/em&gt; the CUDA adapter records - but without &lt;code&gt;--cuda&lt;/code&gt; the adapter is never loaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Add &lt;code&gt;--cuda&lt;/code&gt; to the launch: &lt;code&gt;python -m scorep --cuda --thread=pthread&lt;/code&gt;. A wrinkle: do &lt;strong&gt;not&lt;/strong&gt; add the documented &lt;code&gt;--&lt;/code&gt; script separator - this wrapper version forwards it to &lt;code&gt;scorep-config&lt;/code&gt;, which rejects it (&lt;code&gt;Unknown option: '--'&lt;/code&gt;). After the fix, a &lt;code&gt;CUDA&lt;/code&gt; type appears in &lt;code&gt;scorep-score&lt;/code&gt;, with ~106 named GPU kernel regions per rank - and, crucially, the NCCL collectives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;scorep-score profile.cubex
&lt;span class="go"&gt;flt  type  max_buf[B]     visits  time[s] time[%]  region
     ALL  73,352,737  3,001,818   41.63   100.0   ALL
     USR  73,351,928  2,821,228   36.83    88.5   USR
    CUDA   2,347,618     90,294    3.99     9.6   CUDA   ← GPU kernels now captured

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;scorep-score &lt;span class="nt"&gt;-r&lt;/span&gt; profile.cubex | &lt;span class="nb"&gt;grep &lt;/span&gt;CUDA | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k4&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;span class="go"&gt;  CUDA    700 visits  2.38s  ncclKernel_AllReduce_RING_LL_Sum_float   ← gradient sync
  CUDA     62 visits  0.09s  ncclKernel_AllGather_RING_LL_Sum_int8_t
&lt;/span&gt;&lt;span class="gp"&gt;  CUDA  9,538 visits  0.09s  at::native::unrolled_elementwise_kernel&amp;lt;...&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;  CUDA  8,376 visits  0.08s  at::native::elementwise_kernel&amp;lt;128, 4, ...&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Error 10: CUPTI buffer overflow at 8 ranks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The 1- and 4-GPU traces were clean, but the 8-GPU run dropped records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[CUPTI Activity] Dropped 85222 records. Current buffer size: 1048576 bytes
Proposed minimum SCOREP_CUDA_BUFFER=8889000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; Eight ranks each profiling through CUPTI overran the default 1 MB per-process CUDA activity buffer between flushes, silently discarding kernel records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Score-P told us the answer in the warning. &lt;code&gt;SCOREP_CUDA_BUFFER=64M&lt;/code&gt; for generous headroom. No more dropped records.&lt;/p&gt;




&lt;h2&gt;
  
  
  Error 11: the NCCL watchdog vs. Score-P shutdown race
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The 8-GPU run finished training but then &lt;strong&gt;6 of 8 ranks aborted&lt;/strong&gt; during teardown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terminate called after throwing an instance of 'c10::Error'
  what():  Should never been called   (dummyHasPrimaryContext)
  ... c10d::ProcessGroupNCCL::ncclCommWatchdog()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The aborts struck &lt;em&gt;after&lt;/em&gt; training, killing the process before Score-P flushed its profile - so those ranks left no trace on disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; A shutdown &lt;strong&gt;race&lt;/strong&gt;: PyTorch's background NCCL watchdog thread runs its cleanup destructor (which touches the CUDA device) at interpreter exit, at the same time Score-P tears down its CUDA context. Whichever loses, crashes. It's non-deterministic - a later 4-GPU run lost the race where an 8-GPU run had won it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Remove the race instead of fighting it: call &lt;code&gt;torch.distributed.destroy_process_group()&lt;/code&gt; at the end of &lt;code&gt;train()&lt;/code&gt;, so NCCL is torn down &lt;em&gt;cleanly, before&lt;/em&gt; the interpreter (and Score-P) begin shutdown. With the process group gone, there's no watchdog destructor left to collide with Score-P's teardown, and all eight ranks flush reliably.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final state: per-rank GPU traces collected
&lt;/h2&gt;

&lt;p&gt;After all eleven fixes, every DDP rank ran under its own Score-P measurement, capturing each worker's GPU kernels &lt;em&gt;and&lt;/em&gt; NCCL communication.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncvy88rtsd7wtxamvxr2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncvy88rtsd7wtxamvxr2.png" alt="Vampir Function Summary for an 8-GPU rank:  raw `ncclKernel_AllReduce_RING_LL_Sum_float` endraw  at 2.375 s, sitting right beside  raw `torch.autograd:backward` endraw  at 2.22 s — gradient synchronisation costs as much GPU time as the entire backward pass" width="780" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Samples/sec&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 GPU&lt;/td&gt;
&lt;td&gt;478.8 s&lt;/td&gt;
&lt;td&gt;75.0&lt;/td&gt;
&lt;td&gt;1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 GPU&lt;/td&gt;
&lt;td&gt;103.8 s&lt;/td&gt;
&lt;td&gt;345.9&lt;/td&gt;
&lt;td&gt;4.61×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 GPU&lt;/td&gt;
&lt;td&gt;52.8 s&lt;/td&gt;
&lt;td&gt;680.7&lt;/td&gt;
&lt;td&gt;9.08×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 1 GPU there is zero NCCL; from 4 GPUs the gradient AllReduce appears, and by 8 GPUs &lt;code&gt;ncclKernel_AllReduce&lt;/code&gt; is the single largest GPU activity (~2.375 s, comparable to the entire backward pass) - yet it overlaps backward compute on a separate CUDA stream, which is why throughput still scales near-linearly. The Master Timeline makes the overlap visible: compute runs on the default stream &lt;code&gt;CUDA[0:7]&lt;/code&gt; (&lt;code&gt;CUDA_NULL_STREAM&lt;/code&gt;) while &lt;code&gt;ncclKernel_AllReduce&lt;/code&gt; runs &lt;em&gt;concurrently&lt;/em&gt; on a separate stream &lt;code&gt;CUDA[0:20]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1neg9mq4v9z3tobv7coh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1neg9mq4v9z3tobv7coh.png" alt="Vampir Master Timeline zoomed to a few training steps: dense compute kernels on the default stream  raw `CUDA[0:7]` endraw  run at the same time as  raw `ncclKernel_AllReduce` endraw  blocks on stream  raw `CUDA[0:20]` endraw  — communication overlapped with backward compute, so most of its cost is hidden from wall-clock" width="800" height="252"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What that decomposition &lt;em&gt;means&lt;/em&gt; is the subject of the companion post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Score-P's re-exec is not optional - design around it.&lt;/strong&gt; Any CUDA init that must happen before Score-P's C adapter loads has to happen before the &lt;code&gt;python -m scorep&lt;/code&gt; call in your shell script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The driver and runtime APIs are different things.&lt;/strong&gt; &lt;code&gt;nvidia-smi&lt;/code&gt; passing is necessary but not sufficient. Test the runtime directly (&lt;code&gt;torch.zeros(1).cuda()&lt;/code&gt;), and use &lt;code&gt;try/except&lt;/code&gt;, not &lt;code&gt;is_available()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Module-level imports of the Score-P user API break DDP workers.&lt;/strong&gt; Each rank is a fresh subprocess; lazy-import inside the first method PyTorch guarantees runs after CUDA setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A broken node will burn your budget on CUDA timeouts.&lt;/strong&gt; &lt;code&gt;--exclude&lt;/code&gt; it as soon as you spot it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate the diagnostic trace from the full run.&lt;/strong&gt; CUDA-enabled &lt;code&gt;--max_steps 50&lt;/code&gt; with &lt;code&gt;--evaluation_strategy no&lt;/code&gt; gives clean, size-controlled traces; the full run with CUDA off gives robust scaling stats. You need both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;To trace DDP workers, launch them yourself - don't let &lt;code&gt;torchrun&lt;/code&gt; spawn.&lt;/strong&gt; Replace it with a background loop where each rank is its own &lt;code&gt;python -m scorep&lt;/code&gt; process. This is the single most important structural fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting an env var is not the same as loading the adapter.&lt;/strong&gt; &lt;code&gt;SCOREP_CUDA_ENABLE&lt;/code&gt; configures the CUDA adapter; &lt;code&gt;--cuda&lt;/code&gt; &lt;em&gt;loads&lt;/em&gt; it. Confirm a &lt;code&gt;CUDA&lt;/code&gt; type appears in &lt;code&gt;scorep-score&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile sums overstate communication - use the timeline for wall-clock truth.&lt;/strong&gt; NCCL LL kernels busy-wait, and DDP overlaps AllReduce with backward compute on a separate stream, so summed kernel-time double-counts the overlap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tear down NCCL cleanly so it doesn't race your profiler at exit.&lt;/strong&gt; &lt;code&gt;torch.distributed.destroy_process_group()&lt;/code&gt; at the end of training removes the watchdog before interpreter shutdown.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;The OTF2 traces behind this post were generated on a SLURM cluster (A100-SXM4-40GB) as part of a Score-P performance analysis of DDP training scaling for the DNABERT-2 genomic classifier.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hpc</category>
      <category>gpu</category>
      <category>pytorch</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
