<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ring Zero GPU]]></title><description><![CDATA[Ring Zero GPU]]></description><link>https://sebinrajs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aeab1f23a6a6a6be72f5d2d/a12d4cd2-6113-4dd2-83a8-adabf6243e53.png</url><title>Ring Zero GPU</title><link>https://sebinrajs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 09 Nov 2026 13:25:11 GMT</lastBuildDate><atom:link href="https://sebinrajs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Work That Never Ran: How a Kernel Workqueue Bug Froze a GPU, and the Test That Catches It]]></title><description><![CDATA[The scariest bugs are not the ones that crash. They are the ones where nothing happens, forever. This is the story of a GPU that appeared to hang, a bug…]]></description><link>https://sebinrajs.hashnode.dev/the-work-that-never-ran-how-a-kernel-workqueue-bug-froze-a-gpu-and-the-test-that-catches-it</link><guid isPermaLink="true">https://sebinrajs.hashnode.dev/the-work-that-never-ran-how-a-kernel-workqueue-bug-froze-a-gpu-and-the-test-that-catches-it</guid><category><![CDATA[linux kernel]]></category><category><![CDATA[GPU]]></category><category><![CDATA[workqueue]]></category><category><![CDATA[debugging]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[Sebinraj]]></dc:creator><pubDate>Sun, 08 Nov 2026 17:25:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/11ad9fe2-2468-4554-83b3-7cb1a37703af.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>How one missing list insertion left GPU fences unsignaled forever, and how a stress test turns a hard-reboot hang into a clean failure</h1>
<hr />
<p>The scariest bugs are not the ones that crash. They are the ones where <strong>nothing happens, forever</strong>.</p>
<p>This is the story of a GPU that appeared to hang, a bug that turned out to live nowhere near the GPU code, a one-hunk fix in the kernel's workqueue core, and the stress test I wrote so that a hang like this becomes a clean, bounded test failure instead of a machine that needs a hard reboot.</p>
<h2>In plain English</h2>
<p>Kernels hand a lot of background chores to a system called a <strong>workqueue</strong>: "do this later, on another thread." The GPU driver relies on it for housekeeping, such as signalling that a piece of work has finished.</p>
<p>A rarely used feature, changing which CPUs the workqueue may use, briefly <em>pauses</em> some queues. A bug in the code that <strong>un-pauses</strong> them forgot to write down all but one waiting chore. The forgotten chores never ran. Everything that was waiting for them waited forever, and on the affected system the only way out was a hard reboot.</p>
<p>The fix was a few lines. The test I wrote makes the bug show up in about 30 seconds, and fail cleanly instead of freezing the machine.</p>
<p><strong>Words you'll see in this post</strong></p>
<ul>
<li><strong>Workqueue:</strong> the kernel's way to run deferred work on kernel worker threads.</li>
<li><strong>Work item:</strong> one chore queued on a workqueue.</li>
<li><strong>Worker pool /</strong> <code>kworker</code><strong>:</strong> a group of kernel threads that run the chores, and one such thread.</li>
<li><strong>Bound vs unbound:</strong> bound workers are pinned to one CPU. Unbound workers may run on any allowed CPU.</li>
<li><strong>NUMA node:</strong> a group of CPUs together with their own local memory.</li>
<li><strong>cpumask:</strong> a setting that says which CPUs the unbound workqueue's workers may run on.</li>
<li><strong>CPU migration:</strong> a thread moving from one CPU to another.</li>
<li><strong>Ordered workqueue:</strong> a workqueue that runs one item at a time, in order.</li>
<li><strong>pwq (</strong><code>pool_workqueue</code><strong>):</strong> the link between a workqueue and a worker pool.</li>
<li><strong>Plugged:</strong> temporarily paused. New work is accepted but not started.</li>
<li><strong>Fence:</strong> a signal that says "the GPU has finished this piece of work." Other code waits on it.</li>
</ul>
<h2>The short version</h2>
<ul>
<li><strong>Symptom:</strong> the GPU driver hung because a <strong>fence never signaled</strong>. The machine could only be recovered with a hard reboot.</li>
<li><strong>Root cause:</strong> a bug in the kernel workqueue code, not the GPU driver. When a plugged <strong>ordered</strong> workqueue was unplugged, only the <strong>first</strong> queued work item was activated. The rest were never put on the list of work waiting to run, so they <strong>never ran</strong>.</li>
<li><strong>Trigger:</strong> changing the unbound workqueue <code>cpumask</code> while the GPU driver was queuing work.</li>
<li><strong>Fix:</strong> one extra call in <code>unplug_oldest_pwq()</code> that restores the second bookkeeping step. It was written by Matthew Brost, reviewed on the kernel list, and applied by the workqueue maintainer, Tejun Heo.</li>
<li><strong>Safety net:</strong> a GPU stress test that cycles the cpumask while hammering the driver's bind and unbind path, with a <strong>5-second deadline on every fence</strong>, so a stuck fence means a quick, clean failure instead of a frozen system.</li>
</ul>
<h2>The big picture</h2>
<p>Here is the whole story in one frame. Follow the numbers: the driver queues a chore whose only job is to signal a fence, a <code>cpumask</code> write plugs the queue, the unplug restores only half the bookkeeping, and that chore is never called. The CPUs are idle, the workers are free, and the fence is never signaled.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/bf56630c-5868-4812-a5ab-b548ce3b36a6.png" alt="" style="display:block;margin:0 auto" />

<p>The rest of this post explains each piece, starting with the background you need.</p>
<h2>Background 1: what is a workqueue?</h2>
<p>Kernel code often cannot, or should not, do slow work right where it is. An interrupt handler, for example, must finish fast. So it packages the job as a <strong>work item</strong> (a function plus some data) and puts it on a <strong>workqueue</strong>. A <strong>kernel worker thread</strong> (<code>kworker</code>) picks it up later and runs it in a context where sleeping is allowed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/ab71c4d6-2d3a-4302-9121-458109cc5c63.png" alt="" style="display:block;margin:0 auto" />

<p>Three terms to keep in mind:</p>
<ul>
<li><strong>Workqueue:</strong> the line of chores waiting to be run.</li>
<li><code>pool_workqueue</code> <strong>(pwq):</strong> the link between one workqueue and one pool of workers.</li>
<li><code>max_active</code><strong>:</strong> how many chores from this workqueue may run at the same time.</li>
</ul>
<h2>Background 2: worker pools, CPUs and NUMA nodes</h2>
<p>Creating a new thread for every chore would be wasteful, so the kernel keeps <strong>worker pools</strong>: groups of <code>kworker</code> threads that are reused and that grow and shrink with demand. There are two families.</p>
<ul>
<li><strong>Bound (per-CPU) pools:</strong> every CPU has its own pools, and their workers are <strong>pinned to that one CPU</strong>. This is good for cache locality.</li>
<li><strong>Unbound pools:</strong> their workers are <strong>not tied to a CPU</strong>. The scheduler may place them on any CPU the workqueue allows. They are used for long-running or CPU-hungry work, and they are the only kind that <strong>ordered</strong> workqueues use.</li>
</ul>
<p>Big machines also have <strong>NUMA nodes</strong>: groups of CPUs that sit next to their own local memory. Reaching local memory is fast, and reaching another node's memory is slower. Unbound pools are organised by this topology so that workers stay close to the memory they touch. Many client machines have just one node, and the same code simply uses a single one.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/4f9e9013-a854-4ca0-8a56-d797349f8f5d.png" alt="" style="display:block;margin:0 auto" />

<p>One detail matters for this bug. For unbound workqueues, newer kernels count the number of active chores <strong>per NUMA node</strong>. All the pwqs of a workqueue in one node share that counter, and a <strong>waiting line</strong> for pwqs that need a slot lives next to it. The data structure is called <code>wq_node_nr_active</code>, and the waiting line is its <code>pending_pwqs</code> list.</p>
<h2>Background 3: the cpumask, and CPU migration</h2>
<p>A <strong>cpumask</strong> is a bit mask with one bit per CPU. The file <code>/sys/devices/virtual/workqueue/cpumask</code> holds the set of CPUs that unbound workers are allowed to use. Written in hex:</p>
<table>
<thead>
<tr>
<th>Value</th>
<th>Binary</th>
<th>CPUs allowed</th>
</tr>
</thead>
<tbody><tr>
<td><code>f</code></td>
<td><code>1111</code></td>
<td>CPUs 0-3</td>
</tr>
<tr>
<td><code>ff</code></td>
<td><code>1111 1111</code></td>
<td>CPUs 0-7</td>
</tr>
<tr>
<td><code>fff</code></td>
<td><code>1111 1111 1111</code></td>
<td>CPUs 0-11</td>
</tr>
<tr>
<td><code>ffff</code></td>
<td><code>1111 1111 1111 1111</code></td>
<td>CPUs 0-15</td>
</tr>
</tbody></table>
<p>It exists so administrators can <strong>keep kernel housekeeping off chosen CPUs</strong>, for example to protect a latency-sensitive job.</p>
<p>Changing the mask has real consequences:</p>
<ol>
<li>The unbound pools get a new allowed-CPU set.</li>
<li>The <code>kworker</code> threads are <strong>re-pinned</strong>. A worker that was running on one CPU may be <strong>migrated</strong> to another CPU, or even to another NUMA node. (CPU migration just means a thread moves from one CPU to another.)</li>
<li>In broad terms, each unbound workqueue gets <strong>fresh pwqs</strong> that match the new mask, while the old ones finish their work.</li>
</ol>
<p>This is not a hot path. Almost nobody changes the mask while a GPU workload is running, which is exactly why bugs can hide here. The reproduction script below writes four different masks in under half a second, then repeats that hundreds of times.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/5c2e85d2-6f0d-436e-a6c8-a96aa2e749a0.png" alt="" style="display:block;margin:0 auto" />

<h2>Background 4: ordered workqueues and plugging</h2>
<p>An <strong>ordered workqueue</strong> is an unbound workqueue with <code>max_active = 1</code>: chores run <strong>one at a time, in the order they were queued</strong>.</p>
<p>That promise makes a cpumask change tricky. If the new pwq started running chores while the old pwq still had chores waiting, two chores could run at once, or run out of order. So when the kernel gained the ability to change the cpumask of ordered workqueues (the commit "Enable unbound cpumask update on ordered workqueues"), it did this:</p>
<ul>
<li>the <strong>new</strong> pwq is <strong>plugged</strong>: it accepts new chores, but holds them,</li>
<li>the <strong>old</strong> pwq drains its remaining chores,</li>
<li>once the old pwq is empty, the new pwq is <strong>unplugged</strong> and work resumes.</li>
</ul>
<p>As one reviewer noted, this works because only an ordered workqueue with <code>max_active = 1</code> can be plugged.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/6f00d6ce-582f-4f5c-b156-13a9bf129bb0.png" alt="" style="display:block;margin:0 auto" />

<h2>Background 5: the slot and the waiting line</h2>
<p>Two pieces of bookkeeping decide whether a chore may run:</p>
<ul>
<li><strong>The slot (</strong><code>nr_active</code><strong>):</strong> a counter, like a ticket to run. With <code>max_active = 1</code> there is exactly <strong>one</strong> slot.</li>
<li><strong>The waiting line (</strong><code>pending_pwqs</code><strong>):</strong> a list of pwqs that have chores but could not get a slot. When a slot frees up, the head of the line is called.</li>
</ul>
<p>An item that has been queued but not started is <strong>inactive</strong>. Making it runnable is <strong>activating</strong> it.</p>
<p>When the pwq is <strong>not</strong> plugged, queueing a chore works like this: if a slot is free, the chore takes it. If not, the pwq is added to the waiting line, so it will be called when a slot frees up.</p>
<p><strong>While the pwq is plugged, queueing skips activation.</strong> That means it skips <strong>both</strong> steps: it does not take a slot, and it does not add the pwq to the waiting line.</p>
<blockquote>
<p>The waiting line is the memory of the system. If a pwq is not on it, nobody will ever come back for its work.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/7aaf548f-a134-4e3c-8016-eb30bf5f48c0.png" alt="" style="display:block;margin:0 auto" />

<h2>Background 6: GPU virtual memory, bind and unbind</h2>
<p>The stress test does not poke the workqueue directly. It drives the GPU's <strong>bind and unbind</strong> path as hard as it can, because that path leans on workqueues to finish its work. To follow the test, you need to know what bind and unbind are.</p>
<p><strong>GPU virtual memory.</strong> Just like a CPU process, every GPU client gets its own <strong>virtual address space</strong>, called a <strong>VM</strong>. The GPU does not use physical addresses directly. It translates GPU virtual addresses through <strong>GPU page tables</strong>.</p>
<p><strong>Buffers.</strong> Data lives in <strong>buffer objects</strong> (BOs): chunks of memory the GPU can use.</p>
<p>Three operations make up the life of a mapping:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Bind</strong></td>
<td>Maps a buffer into the VM at a chosen address, by filling in GPU page-table entries.</td>
</tr>
<tr>
<td><strong>Exec</strong></td>
<td>The GPU runs a batch of work that reads and writes the buffer through that mapping.</td>
</tr>
<tr>
<td><strong>Unbind</strong></td>
<td>Removes the mapping, and then <strong>invalidates the GPU's cached translations (the TLB)</strong>, so the GPU can never use a stale mapping. Only then is it safe to free or reuse the memory.</td>
</tr>
</tbody></table>
<p><strong>Bind and unbind are asynchronous.</strong> The caller does not wait inside the call. It passes in a <strong>sync object</strong> and gets a <strong>fence</strong> back, and the fence signals when the operation has fully finished. The work is submitted on a <strong>bind exec queue</strong>, a submission channel used only for bind and unbind. A separate <strong>exec queue</strong> carries the real GPU work.</p>
<p><strong>This is where the workqueue comes in.</strong> Part of the completion and cleanup work, such as finishing the TLB invalidation and releasing buffers, runs as <strong>work items on kernel workqueues</strong>. A fence only signals once that work has run. If a work item is lost, the unbind fence never signals, and whoever waits on it waits forever.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/ba85b9c0-6c4b-48e7-84e3-0b175e58fc57.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Why the test uses this loop.</strong> Each test thread repeatedly <strong>binds, executes, unbinds and waits for the unbind fence</strong>, one thread per GPU engine, while the cpumask keeps changing in the background. That produces a constant stream of completion work items on the workqueue, which is exactly the traffic that exposes the bug.</p>
<h2>The bug: unplug restored only half the bookkeeping</h2>
<p>With the background in place, the bug is easy to state. When the pwq is unplugged, <code>unplug_oldest_pwq()</code> activates the <strong>first</strong> inactive work item. That part is correct.</p>
<p>But suppose <strong>several</strong> items were queued while the pwq was plugged. The first one is activated. The second, third and fourth are inactive, and because queueing skipped the "add to <code>pending_pwqs</code>" step, the pwq is <strong>not on the list</strong>.</p>
<p>So nobody ever comes back for them.</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>What should happen</th>
<th>What actually happened</th>
</tr>
</thead>
<tbody><tr>
<td>Pwq is plugged, 4 items are queued</td>
<td>Items wait, remembered by the pending list</td>
<td>Items wait, but <strong>the pending list is never updated</strong></td>
</tr>
<tr>
<td>Pwq is unplugged</td>
<td>First item runs, the rest are queued behind it on the pending list</td>
<td>First item runs; <strong>the rest are not on the list</strong></td>
</tr>
<tr>
<td>First item finishes</td>
<td>The next item is activated</td>
<td>Nothing is on the list, so <strong>nothing is activated</strong></td>
</tr>
<tr>
<td>Result</td>
<td>All four items run</td>
<td><strong>Three items never run. Ever.</strong></td>
</tr>
</tbody></table>
<p>Think of a restaurant. While the door is closed for cleaning, new parties are seated at tables but their names are not written on the waiting list. When the door reopens, the host calls the first party. The others sit at their tables, and the host never calls their names, because no name is on the list.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/607c43c5-da8b-4506-b9fb-6fd2c380934b.png" alt="" style="display:block;margin:0 auto" />

<h2>Why a GPU hangs because of this</h2>
<p>The GPU driver queues housekeeping on workqueues: cleaning up buffers after a bind or unbind, completing a TLB invalidation, and so on. A fence only signals once that housekeeping has run.</p>
<p>If the work item is one of the forgotten ones:</p>
<ol>
<li>the work item never runs,</li>
<li>the fence it was supposed to complete <strong>never signals</strong>,</li>
<li>anything waiting on that fence, often in an <strong>uninterruptible</strong> wait, waits forever.</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/46bb3024-4821-49ee-bb8d-04d81aaa4a02.png" alt="" style="display:block;margin:0 auto" />

<p>From the outside it looks like <strong>a GPU hang</strong>, even though the GPU did nothing wrong. As the original report put it, the driver "appeared to hang due to a fencing signal not completing", and it was traced to work items not being scheduled. The author also noted that it had been lurking around for a while, and that the driver had been working around it for a couple of years.</p>
<h2>Reproducing it</h2>
<p>The fix author's reproduction used two shells:</p>
<pre><code class="language-bash"># shell 0: hammer the GPU driver's rebind path, over and over
for i in {1..100}; do run-the-gpu-rebind-test; done

# shell 1: keep changing the unbound workqueue cpumask
for i in {1..1000}; do
    echo f    &gt; /sys/devices/virtual/workqueue/cpumask
    echo ff   &gt; /sys/devices/virtual/workqueue/cpumask
    echo fff  &gt; /sys/devices/virtual/workqueue/cpumask
    echo ffff &gt; /sys/devices/virtual/workqueue/cpumask
    sleep .1
done
</code></pre>
<p>Shell 1 plugs and unplugs the ordered workqueue again and again. Shell 0 keeps queueing work onto it. Sooner or later several items are queued during a plug window, and the machine freezes.</p>
<h2>The fix</h2>
<pre><code class="language-diff"> 	if (pwq-&gt;plugged) {
 		pwq-&gt;plugged = false;
-		if (pwq_activate_first_inactive(pwq, true))
+		if (pwq_activate_first_inactive(pwq, true)) {
+			pwq_activate_first_inactive(pwq, false);
+
 			kick_pool(pwq-&gt;pool);
+		}
 	}
</code></pre>
<p>The first call activates the first inactive item. The <strong>second call</strong>, with <code>fill = false</code>, puts the pwq back on <code>pending_pwqs</code> if more items are waiting. That restores both bookkeeping steps that queueing had skipped.</p>
<h2>The test: make the hang cheap to catch</h2>
<p>A fix without a test can come back. But a test for this bug has a nasty property: <strong>when it fails, it hangs the machine.</strong></p>
<p>So the real design challenge was not "reproduce the bug". It was "reproduce the bug <strong>without taking the test machine down</strong>".</p>
<p>The subtest is <code>threads-wq-stress-rebind-bindexecqueue</code>, added to the driver's exec-threads IGT test. It was posted to the igt-dev list as v3 and received a Reviewed-by and later merged upstream.</p>
<h3>The design at a glance</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/b00c56b0-ec43-403b-b697-f243a1ff7cbd.png" alt="" style="display:block;margin:0 auto" />

<h3>What it does</h3>
<ul>
<li><strong>Per-engine threads</strong> continuously unbind and rebind the VM, using a separate bind exec queue for each slot.</li>
<li>A <strong>helper process</strong> rapidly cycles the unbound workqueue cpumask through wider and wider CPU sets (<code>f</code>, <code>ff</code>, <code>fff</code>, <code>ffff</code>) every 100 ms, just like the shell reproducer.</li>
<li>The test runs for up to <strong>30 seconds</strong>.</li>
</ul>
<h3>The trick: every fence has a deadline</h3>
<p>Normally the test would wait for a fence with no timeout. A missing signal would then hang the test forever. Under the new <code>WQ_STRESS</code> flag it waits with a <strong>5-second deadline</strong> at three checkpoints:</p>
<ol>
<li>the per-exec-queue fences,</li>
<li>the bind-chain fence,</li>
<li>the unbind fence, which covers <strong>TLB invalidation</strong>, the most likely place for a stuck work item to show up.</li>
</ol>
<pre><code class="language-c">static int64_t stress_fence_deadline(void)
{
	struct timespec ts;

	clock_gettime(CLOCK_MONOTONIC, &amp;ts);
	return (int64_t)ts.tv_sec * NSEC_PER_SEC + ts.tv_nsec +
	       WQ_FENCE_TIMEOUT_NS;   /* 5 seconds from now */
}
</code></pre>
<p>If any fence misses its deadline, the first thread to notice sets a shared <strong>atomic flag</strong>, and every other thread bails out immediately instead of hammering a hung kernel for the rest of the 30 seconds.</p>
<h3>The trick: keep the GPU work in a child process</h3>
<p>All GPU work runs in a <strong>forked child</strong>. The parent only watches.</p>
<ul>
<li>The child reports its result through a <strong>pipe</strong>: one byte, pass or fail.</li>
<li>The parent waits on the pipe with <code>poll()</code> and a <strong>60-second timeout</strong>. If nothing arrives, that is treated as a hang too.</li>
<li>The parent restores the original cpumask whatever happens, so a failing test does not leave the machine with a changed CPU mask.</li>
</ul>
<h3>The trick: do not clean up a hung GPU</h3>
<p>This part took the most thought. When a hang is detected, the obvious cleanup is to destroy the exec queues, buffers and VM, and close the DRM file descriptor. <strong>Every one of those can block forever</strong>, because they wait for pending GPU work, and that work is exactly what is stuck. Closing the fd would sit in an uninterruptible wait for the fence.</p>
<p>So when a hang is detected, the child:</p>
<ol>
<li>logs a critical message,</li>
<li>drops the page cache and <strong>forces a GPU reset</strong> to unblock fences that are still pending,</li>
<li>writes its result <strong>before</strong> exiting,</li>
<li>exits immediately with <code>_exit()</code>, <strong>without closing the fd</strong>,</li>
<li>and <strong>skips all teardown</strong> of exec queues, buffers and VM. The kernel reclaims them when the process dies.</li>
</ol>
<p>Writing the result <em>before</em> exiting matters: the parent already has its answer and does not have to wait for a child that may be stuck in an uninterruptible state.</p>
<h3>Fail loudly, and abort</h3>
<p>If a hang is confirmed, the test <strong>aborts</strong> rather than just marking a failure. A child stuck in an uninterruptible <code>D+</code> state can interfere with later tests, so continuing would only produce confusing results. Aborting makes it clear that this machine needs a reboot, instead of letting a hang pollute the rest of the run. That behaviour came out of review: v2 changed it from "fail" to "abort".</p>
<p>The test also <strong>skips</strong> cleanly if it is not run as root, since it needs to write the workqueue cpumask.</p>
<h2>Debugging tips</h2>
<ul>
<li>Watch the workqueue cpumask: <code>cat /sys/devices/virtual/workqueue/cpumask</code></li>
<li>See stuck workers: <code>echo w &gt; /proc/sysrq-trigger</code> and read the blocked-tasks dump in <code>dmesg</code></li>
<li>A process in <code>D</code> state forever is the classic sign of a lost wake-up: check what it is waiting on with <code>cat /proc/&lt;pid&gt;/stack</code></li>
<li>If a fence never signals, look for the work item that should have signalled it, not at the GPU</li>
</ul>
<h2>Status</h2>
<p>The kernel fix was applied by the workqueue maintainer to the fixes branch for the 7.0 cycle: <a href="https://patchwork.freedesktop.org/patch/715805/">the patch on patchwork</a>. The IGT test is merged, the commit link <a href="https://lists.freedesktop.org/archives/igt-dev/2026-April/108896.html">the patch on igt-dev</a>.</p>
<h2>What I learned</h2>
<ul>
<li><strong>The symptom is not the location.</strong> A "GPU hang" was a kernel workqueue bug. When a fence never signals, ask who was supposed to signal it, and whether that code ever ran.</li>
<li><strong>Bookkeeping bugs are the quietest bugs.</strong> Nothing crashed, nothing printed. One list was missing one insertion, and the work simply was never called.</li>
<li><strong>Pause and resume paths deserve extra tests.</strong> The bug lived in the code that restores state after a temporary pause, a path that most workloads rarely exercise.</li>
<li><strong>A test that can hang the machine needs an exit plan.</strong> Deadlines on waits, work in a child process, a result written before exit, and no cleanup of something that is stuck turn a frozen system into a failed test.</li>
</ul>
<hr />
<p><em>Views are my own and not those of my employer. Kernel and test code shown is from public mailing-list patches.</em></p>
<p><em>I work on Linux graphics drivers and GPU tooling. If you have chased a "GPU hang" that turned out to live somewhere else, I would love to hear about it.</em></p>
]]></content:encoded></item><item><title><![CDATA[Why My GPU Lost Half Its Speed: Chasing a 48% Benchmark Drop Down to Memory Fragmentation]]></title><description><![CDATA[A 4K graphics benchmark that should score 4,667 came back with 2,445. Nearly half the performance was gone. The GPU was fine, the clocks were fine, and the…]]></description><link>https://sebinrajs.hashnode.dev/why-my-gpu-lost-half-its-speed-chasing-a-48-benchmark-drop-down-to-memory-fragmentation</link><guid isPermaLink="true">https://sebinrajs.hashnode.dev/why-my-gpu-lost-half-its-speed-chasing-a-48-benchmark-drop-down-to-memory-fragmentation</guid><category><![CDATA[GPU]]></category><category><![CDATA[linux kernel]]></category><category><![CDATA[drm]]></category><category><![CDATA[Performance Optimization]]></category><category><![CDATA[memory-management]]></category><dc:creator><![CDATA[Sebinraj]]></dc:creator><pubDate>Wed, 04 Nov 2026 18:48:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/985ea6e2-2340-401b-8c60-90616bca4aa4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>How a starved GPU settled for 4 KB pages, and how its driver learned to wait for 2 MB ones</h1>
<hr />
<p>A 4K graphics benchmark that should score <strong>4,667</strong> came back with <strong>2,445</strong>. Nearly half the performance was gone. The GPU was fine, the clocks were fine, and the thermals were fine. The problem was <em>how the kernel handed out memory</em>.</p>
<p>This post is about that bug, why it only showed up under heavy load, and the RFC patch I wrote to fix it.</p>
<h2>In plain English</h2>
<p>When a GPU needs memory, it likes <strong>big contiguous chunks</strong>. Big chunks are cheaper for the GPU to address, so it runs faster. When the system has been running for a while, RAM becomes <strong>fragmented</strong>: lots of free memory, but chopped into tiny pieces. If big chunks are not free at that moment, the driver quietly settles for tiny 4 KB pages instead. Nothing fails, and nothing logs an error. The workload just gets slower.</p>
<p>There is one more ingredient: the slowdown only appeared when the <strong>IOMMU</strong> was switched on. The IOMMU adds a second layer of address translation between the GPU and RAM, and tiny pages make that extra layer far more expensive.</p>
<p>The fix: let the driver notice when the GPU is about to ask for a lot of memory, and in that case <strong>wait for the kernel to make big chunks available</strong> instead of giving up immediately.</p>
<p><strong>Words you'll see in this post</strong></p>
<ul>
<li><strong>Page:</strong> the basic unit of memory, normally 4 KB.</li>
<li><strong>Order:</strong> the size of a block of pages. Order <em>n</em> means 2ⁿ contiguous pages, so order 9 is 512 pages = <strong>2 MB</strong>.</li>
<li><strong>Fragmentation:</strong> free memory that is split into small pieces, so no big block is available.</li>
<li><strong>Reclaim / compaction:</strong> the kernel's work to free up pages and shuffle them together to rebuild big blocks.</li>
<li><strong>TTM:</strong> the generic GPU memory manager in the Linux DRM subsystem.</li>
<li><strong>IOMMU:</strong> a hardware unit that sits between a device (here, the GPU) and RAM and translates the addresses the device uses into real physical addresses. It is to devices what the MMU is to CPUs.</li>
<li><strong>IOVA (I/O virtual address):</strong> the address a device uses when the IOMMU is on. It is not a physical address. The IOMMU translates it.</li>
<li><strong>TLB / IOTLB:</strong> a small cache of recent address translations. The one in the IOMMU is the IOTLB. A miss means a slow walk of the page tables in memory.</li>
<li><strong>TLB invalidation:</strong> throwing cached translations away when a mapping changes, so nobody uses a stale one.</li>
</ul>
<h2>The short version</h2>
<ul>
<li><strong>Symptom:</strong> a 4K GPU benchmark scored <strong>2,445 instead of 4,667</strong> on an integrated GPU with 16 GB of RAM.</li>
<li><strong>Condition:</strong> it only happened with the <strong>IOMMU enabled</strong>. With it off, the same workload was fine.</li>
<li><strong>Why the IOMMU matters:</strong> it adds a second translation layer (IOVA to physical). 4 KB pages mean many more IOMMU entries, more <strong>IOTLB misses</strong> and a much higher <strong>TLB invalidation rate</strong>.</li>
<li><strong>Clue:</strong> dropping the kernel caches (<code>echo 3 &gt; /proc/sys/vm/drop_caches</code>) before the run restored full performance, which pointed at fragmentation.</li>
<li><strong>Cause:</strong> under a heavy allocation burst, no order-9 (2 MB) blocks were free. The GPU memory manager made an <em>opportunistic</em> attempt for big pages, did not wait, and <strong>fell back to 4 KB pages</strong>.</li>
<li><strong>Fix (RFC):</strong> a small <strong>memory monitor</strong> in the driver. It measures how fast GPU memory is growing, using an exponential moving average, and checks whether order-9 blocks are scarce. When both say "a burst is coming and big pages are short", it switches allocation into a <strong>high-throughput mode</strong> that is allowed to wait for reclaim.</li>
<li><strong>Why two modes:</strong> direct reclaim stalls the task that asked for memory. That is acceptable for a heavy workload and unacceptable for browsing, UI and video, so the driver pays that cost only when a burst is detected.</li>
<li><strong>Safe by default:</strong> the whole thing is behind a module parameter and is off unless enabled.</li>
</ul>
<h2>Background: why the GPU cares about page size</h2>
<p>Every buffer the GPU touches is mapped through GPU page tables. A buffer backed by 2 MB pages needs 512 times fewer entries than the same buffer backed by 4 KB pages. That means fewer page-table walks and better TLB behaviour, which matters when a benchmark streams gigabytes of textures and render targets.</p>
<h2>The missing piece: the IOMMU and its extra translation layer</h2>
<p>So far I described one layer of translation: the GPU's own page tables. With the <strong>IOMMU</strong> enabled there is a second one underneath.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/161d8776-6850-448a-80bf-47ed35ceca9e.png" alt="" style="display:block;margin:0 auto" />

<p><strong>Without an IOMMU</strong>, the GPU is handed physical addresses. What the driver programs is what the memory bus sees. There is no extra step.</p>
<p><strong>With an IOMMU</strong>, the GPU never sees physical addresses. For each buffer, the kernel's DMA layer allocates a range of <strong>IOVAs</strong> (I/O virtual addresses) and fills the IOMMU's page tables so that each IOVA maps to a physical page. The GPU is given the IOVAs. Every system-memory access it makes then goes through two lookups:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/743ae058-6f81-4e25-aba5-a6ef91d9b4bf.png" alt="" style="display:block;margin:0 auto" />

<p>The IOMMU is there for good reasons: it isolates devices from memory they shouldn't touch, and it is a building block for virtualization. But it is not free, and <strong>the cost depends heavily on page size</strong>:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Backed by 2 MB pages</th>
<th>Backed by 4 KB pages</th>
</tr>
</thead>
<tbody><tr>
<td>IOMMU entries for a 2 MB region</td>
<td><strong>1</strong> (a single superpage entry)</td>
<td><strong>512</strong></td>
</tr>
<tr>
<td>IOTLB reach</td>
<td>one entry covers 2 MB</td>
<td>one entry covers 4 KB</td>
</tr>
<tr>
<td>IOTLB misses under a streaming workload</td>
<td>few</td>
<td><strong>many</strong>, each one a walk of the IOMMU page tables in RAM</td>
</tr>
<tr>
<td>Work to unmap and invalidate</td>
<td>one invalidation</td>
<td>up to 512 entries to tear down and flush</td>
</tr>
</tbody></table>
<p>So low-order pages hurt twice:</p>
<ol>
<li><strong>More TLB misses.</strong> The same buffer needs 512 times as many translations, far beyond what the IOTLB can cache. Constant misses mean constant slow table walks, and the GPU stalls waiting for them.</li>
<li><strong>A higher TLB invalidation rate.</strong> Whenever buffers are freed, evicted or remapped, the IOMMU mappings change and the cached translations must be invalidated. With 4 KB pages there are many more mappings to change, so invalidations are far more frequent, and each one costs time on the same path the GPU is trying to use.</li>
</ol>
<p>That is why the bug <strong>only appeared with the IOMMU on</strong>. With it off, 4 KB pages still cost something in the GPU's own page tables, but there is no second layer to multiply the damage. With it on, the fallback from 2 MB to 4 KB turned a mild inefficiency into a halving of the benchmark score.</p>
<p>So when the GPU memory manager can get 2 MB blocks, performance is good. When it can't, it still <em>works</em>, which is exactly what makes this bug hard to spot.</p>
<h2>How a GPU buffer gets its memory</h2>
<p>The stack looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/a46413e9-650e-4e8e-acef-b2640216d3b9.png" alt="" style="display:block;margin:0 auto" />

<p>Step 3 is where the story turns.</p>
<h2>The bug: an optimistic allocator that never waits</h2>
<p>The big-order attempts in TTM are deliberately <strong>opportunistic</strong>. They are not allowed to stall, because blocking the whole system for a nice-to-have 2 MB block would be a bad trade in general. So the allocator asks the buddy system for a 2 MB block, and if none is free <em>right now</em>, it does not wait for reclaim or compaction. It just falls back to a smaller order.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/9da27cea-2630-48f1-9ede-a54b4ca9c12b.png" alt="" style="display:block;margin:0 auto" />

<p>On a freshly booted machine this is invisible: big blocks are everywhere. On a machine that has been busy, with 16 GB of RAM mostly spoken for and a heavy 4K workload allocating GPU memory in a burst, the picture is different:</p>
<table>
<thead>
<tr>
<th>What the kernel sees</th>
<th>What happens</th>
</tr>
</thead>
<tbody><tr>
<td>Lots of free memory, but in small pieces</td>
<td>Order-9 allocation fails immediately</td>
</tr>
<tr>
<td>The opportunistic attempt is not allowed to wait</td>
<td>TTM falls back to 4 KB pages</td>
</tr>
<tr>
<td>The GPU gets millions of small pages</td>
<td>Larger GPU page tables, and 512x more IOMMU entries (when the IOMMU is on)</td>
</tr>
<tr>
<td>Every access needs two translations</td>
<td>More IOTLB misses and a higher invalidation rate</td>
</tr>
<tr>
<td>The benchmark keeps running, just slower</td>
<td><strong>2,445 instead of 4,667</strong></td>
</tr>
</tbody></table>
<p>I could see this in <code>/proc/buddyinfo</code>: the high-order columns, order 9 and above, were nearly empty while the low-order columns held plenty of free memory.</p>
<blockquote>
<p>That is external fragmentation: the memory exists, it is just in the wrong shape.</p>
</blockquote>
<h3>How the issue was narrowed down</h3>
<ul>
<li><p>The regression correlated with <strong>allocation behaviour</strong>, not with GPU frequency or power.</p>
</li>
<li><p>The key clue: it <strong>disappeared when the IOMMU was disabled</strong>. That ruled out the GPU itself and pointed at the extra translation layer, and from there at page size.</p>
</li>
<li><p><strong>The decisive experiment: drop the caches.</strong> Before a run, flush the kernel's reclaimable memory from the command line:</p>
<pre><code class="language-bash">sync
echo 3 &gt; /proc/sys/vm/drop_caches    # as root
</code></pre>
<p><code>sync</code> writes dirty data to storage first, so the pages can be dropped safely. Writing <code>3</code> frees the clean <strong>page cache</strong> plus <strong>dentries and inodes</strong> (<code>1</code> is page cache only, <code>2</code> is dentries and inodes only). All that memory goes back to the free lists, and neighbouring free pages merge back into big blocks. After that, the benchmark <strong>ran at full speed</strong>. The same run without the drop was slow. That is exactly what you'd expect if the problem is <em>shape</em>, not <em>amount</em>: the memory was already there, it just wasn't in large contiguous pieces until the caches were released.</p>
</li>
<li><p>Comparing <code>/proc/buddyinfo</code> before and during the run showed high-order free blocks collapsing under load.</p>
</li>
<li><p>The place where TTM's page-allocation flags are chosen is exactly where a driver-level hook can influence the behaviour, and changing the flags there recovered the lost performance. That pointed straight at "the allocator gave up too early".</p>
</li>
</ul>
<h2>Why dropping caches is a diagnosis, not a fix</h2>
<p>Dropping caches proved the theory, but it can't be the solution. It throws away useful cached files, so everything gets slower for a while as they are re-read. It needs root, and nobody can run it by hand before every game or benchmark. The driver has to get the same effect <strong>by itself, at the right moment</strong>: ask the kernel to reclaim memory when, and only when, a burst of GPU allocations is arriving.</p>
<h2>The idea: wait only when it's worth waiting</h2>
<p>The obvious fix is "always allow the allocator to wait for big pages". That is a bad idea, for the reasons below.</p>
<h2>Why two modes? Because most of the time, nobody needs to wait</h2>
<p>If waiting for big pages fixes the benchmark, why not wait <em>all the time</em>? Because <strong>direct reclaim is a tax paid by whoever is asking for memory</strong>. When an allocation can't be satisfied, the kernel makes that very task stop and clean house: write back pages, evict caches, compact memory. Until it finishes, the task is stuck.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/64a727aa-f897-494a-a62c-df7a4a6fe37b.png" alt="" style="display:block;margin:0 auto" />

<p>For a benchmark that streams gigabytes, that stall is a rounding error next to the speed-up. For everyday use it is the opposite: a stall of even a few milliseconds is a <strong>visible hitch</strong>.</p>
<table>
<thead>
<tr>
<th>Workload</th>
<th>What the GPU memory looks like</th>
<th>What you want</th>
</tr>
</thead>
<tbody><tr>
<td>Casual browsing, scrolling</td>
<td>small, occasional allocations</td>
<td><strong>Never stall.</strong> Take any free page now.</td>
</tr>
<tr>
<td>UI and animations</td>
<td>steady, small, latency-sensitive (a frame budget of about 16 ms)</td>
<td><strong>Never stall.</strong> A missed frame is a visible jank.</td>
</tr>
<tr>
<td>Playing a video</td>
<td>a few buffers, reused frame after frame</td>
<td><strong>Never stall.</strong> Big pages would barely help.</td>
</tr>
<tr>
<td>Heavy 3D, a 4K benchmark, a big game</td>
<td>a <strong>burst</strong> of huge allocations that then stay in use for minutes</td>
<td><strong>Wait.</strong> Reclaim now, get 2 MB pages, run faster for the whole session.</td>
</tr>
</tbody></table>
<p>The trade-off flips completely depending on the workload:</p>
<ul>
<li><strong>Small, steady allocations:</strong> the cost of waiting is paid <em>immediately and repeatedly</em>, and the benefit is tiny. Fast 4 KB fallback wins.</li>
<li><strong>A big burst:</strong> the cost of waiting is paid <em>once</em>, and the benefit lasts as long as the workload does. Waiting wins by a mile.</li>
</ul>
<p>A single fixed policy gets one of these wrong, whichever way you set it. "Never wait" costs us half the benchmark score. "Always wait" makes a quiet browsing session feel sluggish. So the driver needs <strong>two modes</strong>, and a way to decide, <em>right now</em>, which situation it is in:</p>
<ul>
<li><strong>Latency mode (default):</strong> no direct reclaim. Allocation stays instant. This is what you're in 99% of the time while browsing, scrolling and watching videos.</li>
<li><strong>High-throughput mode:</strong> direct reclaim is allowed, so the allocator can wait for big blocks. This is used only during a heavy GPU workload.</li>
</ul>
<p>The question is how to detect "a burst is happening" without being fooled by tiny spikes.</p>
<h2>The memory monitor</h2>
<p>I added a small periodic worker, <code>mem_monitor</code>, in the driver. It does two jobs.</p>
<h3>1. Measure how fast GPU memory is growing</h3>
<p>The driver already keeps a counter of the total GPU memory pages in use. It is the same counter that feeds the <code>gpu_mem_total</code> tracepoint. Every sample interval, the monitor reads it and computes:</p>
<ol>
<li><p><strong>Delta (growth):</strong> how much the memory grew since the last sample. If it shrank or stayed flat, the delta is clamped to 0.</p>
<p><code>delta(t) = max(0, mem(t) - mem(t-1))</code></p>
</li>
<li><p><strong>Exponential moving average (EMA):</strong> blend the new growth with history.</p>
<p><code>EMA(t) = alpha * delta(t) + (1 - alpha) * EMA(t-1)</code></p>
<p>The patch uses a fixed <code>alpha = 0.8</code>: 80% weight on the latest growth, 20% on everything before it.</p>
</li>
</ol>
<p>An <code>alpha</code> of 0.8 makes the monitor a <strong>low-pass filter that reacts quickly</strong>. A real allocation burst pushes the average up within a sample or two, while a single tiny spike is smoothed away.</p>
<p>If the EMA crosses a threshold (default <strong>250 MB per interval</strong>), the driver switches from latency mode to <strong>high-throughput mode</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/37269e37-d74b-4875-a3d7-87cc6fa2b074.png" alt="" style="display:block;margin:0 auto" />

<h3>2. Don't flap</h3>
<p>Switching modes back and forth would be worse than either mode. So the switch back to latency mode requires several <strong>consecutive below-threshold samples</strong> (the default is 5). That gives a built-in minimum dwell time of <code>debounce_count * poll_ms</code>, with no second timer or second alpha to tune.</p>
<h3>3. Check whether big pages are actually scarce</h3>
<p>A growth burst alone isn't enough. If order-9 blocks are plentiful, the allocator will find them without waiting. The patch also gates the wake-ups on <strong>order-9 external fragmentation</strong>, using the free-block counts that <code>/proc/buddyinfo</code> exposes. So the expensive behaviour is only used when <em>both</em> conditions hold: GPU memory is growing fast, and big blocks are short.</p>
<h3>4. Be good citizens</h3>
<ul>
<li><strong>Power:</strong> the worker goes to sleep after a period with no significant activity in latency mode (default 10 s) and is re-armed when activity returns.</li>
<li><strong>Suspend and resume:</strong> the worker is paused and resumed across system and runtime power-management transitions.</li>
<li><strong>Opt-in:</strong> everything is behind module parameters and off by default.</li>
</ul>
<h2>How the pieces connect</h2>
<ol>
<li>The <strong>monitor</strong> samples the GPU page counter and checks order-9 availability.</li>
<li>It publishes a mode: <strong>latency</strong> or <strong>high-throughput</strong>.</li>
<li>A small set of <strong>vendor hooks</strong> on the TTM page-allocation path reads that mode.</li>
<li>In high-throughput mode the hooks let TTM's allocation attempts <strong>wait for reclaim and compaction</strong> instead of bailing out. In latency mode nothing changes.</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/76a79100-9d74-4f28-a901-01570a1db478.png" alt="" style="display:block;margin:0 auto" />

<p>The first patch adds the monitor, the second registers the hooks, the third adds the fragmentation gate, and the fourth adds the safe enable toggle. The RFC covers all four.</p>
<h2>Observing it</h2>
<ul>
<li>Watch the mode flip with a tracepoint: <code>cat /sys/kernel/debug/tracing/events/&lt;driver&gt;/&lt;mode_switch_event&gt;</code>.</li>
<li>Watch fragmentation live: <code>watch -n1 cat /proc/buddyinfo</code> and look at the orders 9 and above.</li>
<li>Compare <code>gpu_mem_total</code> against the mode switches to see the monitor reacting to a burst.</li>
</ul>
<h2>What I learned</h2>
<ul>
<li><strong>"It works" can hide a performance bug.</strong> A fallback path that never fails never raises an alarm. It just quietly costs you half your frame rate.</li>
<li><strong>Every translation layer multiplies the cost of small pages.</strong> The IOMMU is worth having, but it turns "slightly less efficient" into "twice as slow" when pages are tiny. Test with the IOMMU both on and off.</li>
<li><strong>Fragmentation is invisible in</strong> <code>free</code><strong>.</strong> Free memory can be high while <em>useful</em> free memory is low. <code>/proc/buddyinfo</code> tells the real story.</li>
<li><strong>Don't fix a latency trade-off globally.</strong> "Always wait" would have helped one benchmark and hurt every interactive workload. The monitor makes the trade-off only when the workload justifies it.</li>
<li><strong>Smooth, then threshold, then debounce.</strong> An EMA removes noise, a threshold makes the decision, and the debounce stops it flapping. Three small ideas make a stable control loop.</li>
<li><strong>Ship risky behaviour off by default.</strong> An RFC with a kill switch is much easier to review than one without.</li>
</ul>
<h2>Status</h2>
<p>This is an <strong>RFC</strong>: the patches are posted for discussion and feedback, not merged. You can read the thread here: <a href="https://lists.freedesktop.org/archives/intel-xe/2026-September/139585.html">RFC: Dynamic GPU Memory Monitor</a>. Thoughts, objections and better ideas are welcome.</p>
<hr />
<p><em>I work on Linux graphics, display drivers and GPU tools. If you have fought a fragmentation bug of your own, I'd love to hear how you tackled it.</em></p>
]]></content:encoded></item><item><title><![CDATA[A 32-bit Truncation, Three Fences and a GPU Test: Catching an Explicit-Fencing Bug in the Display Driver]]></title><description><![CDATA[Some kernel bugs hide for a long time because the code path that triggers them is simply never exercised by the usual software stack. This is the story of…]]></description><link>https://sebinrajs.hashnode.dev/a-32-bit-truncation-three-fences-and-a-gpu-test-catching-an-explicit-fencing-bug-in-the-display-driver</link><guid isPermaLink="true">https://sebinrajs.hashnode.dev/a-32-bit-truncation-three-fences-and-a-gpu-test-catching-an-explicit-fencing-bug-in-the-display-driver</guid><category><![CDATA[linux kernel]]></category><category><![CDATA[graphics]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Android]]></category><category><![CDATA[Display Driver]]></category><category><![CDATA[Open Source GPU Tools]]></category><category><![CDATA[Bugs and Errors]]></category><category><![CDATA[GPU]]></category><dc:creator><![CDATA[Sebinraj]]></dc:creator><pubDate>Wed, 04 Nov 2026 06:13:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/90d94721-a1c6-46da-8f16-d675066b565c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>How a <code>long</code> stored in an <code>int</code> made the display skip a fence wait, and how the GPU test I wrote keeps it from coming back</h1>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/4807aed9-9424-4a52-b605-ee049507c423.png" alt="" style="display:block;margin:0 auto" />

<hr />
<p>Some kernel bugs hide for a long time because the code path that triggers them is simply never exercised by the usual software stack. This is the story of one of those bugs in the display code used by the GPU driver, and the GPU test I wrote so it can't come back.</p>
<h2>In plain English</h2>
<p>Your screen is built from layers (called <em>planes</em>). Before showing a new frame, the display driver has to wait until the GPU has finished drawing <strong>every</strong> layer. A tiny type mistake, a big number squeezed into a smaller box, made the driver think the waiting had failed, so it stopped early and showed unfinished frames. The result was flicker and visual glitches. The fix was one line, and I wrote the test that makes sure it can't come back.</p>
<p><strong>Words you'll see in this post</strong></p>
<ul>
<li><strong>Fence:</strong> a signal that says "the GPU has finished this piece of work."</li>
<li><strong>Plane:</strong> one layer of the screen, such as the background, a video or a pop-up.</li>
<li><strong>Atomic commit:</strong> updating all the layers together in one step, all or nothing.</li>
<li><strong>Explicit fencing:</strong> the app hands its fences to the kernel itself (as Android does), instead of the kernel tracking them behind the scenes.</li>
</ul>
<h2>The short version</h2>
<ul>
<li><strong>Trigger:</strong> Android's compositor passes <strong>explicit fences</strong> to the kernel, including already-finished placeholder ("stub") fences on some planes, next to real pending ones.</li>
<li><strong>Bug:</strong> in a function in Display Driver Code <code>&lt;gpu&gt;_atomic_commit_fence_wait()</code>, <code>dma_fence_wait_timeout()</code> returns a 64-bit <code>long</code>, but the result was stored in a 32-bit <code>int</code>. For an already-signaled fence that value is <code>MAX_SCHEDULE_TIMEOUT</code> (<code>0x7FFFFFFFFFFFFFFF</code>). Only the lower 32 bits survived, <code>0xFFFFFFFF</code>, which is <code>-1</code>, and the loop read that as an error.</li>
<li><strong>Effect:</strong> the driver <strong>skipped waiting on the remaining fences</strong>, so a GPU-drawn layer was shown before it was ready: flicker and visual artifacts.</li>
<li><strong>Fix:</strong> a one-line type change (<code>int</code> to <code>long</code>).</li>
<li><strong>Safety net:</strong> a GPU test I wrote that reproduces the exact mix of signaled and pending fences, so the bug can't return unnoticed.</li>
</ul>
<h2>Background: what is a fence?</h2>
<p>A fence is a synchronization primitive, exposed to userspace as a file descriptor. It has three states:</p>
<table>
<thead>
<tr>
<th>State</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td>Unsignaled (0)</td>
<td>the operation is still in progress</td>
</tr>
<tr>
<td>Signaled (1)</td>
<td>the operation has completed</td>
</tr>
<tr>
<td>Error (-1)</td>
<td>the operation failed</td>
</tr>
</tbody></table>
<p>In the display (KMS) world there are two fence properties:</p>
<ul>
<li><code>IN_FENCE_FD</code> (per plane): "do not display this buffer until this fence signals."</li>
<li><code>OUT_FENCE_PTR</code> (per CRTC): "give me a fence that signals when this update has reached the screen."</li>
</ul>
<h2>Implicit vs explicit fencing</h2>
<ul>
<li><strong>Implicit fencing:</strong> the kernel attaches fences to buffers automatically and userspace never sees them. The kernel must track them all.</li>
<li><strong>Explicit fencing:</strong> userspace owns the fences. It passes them around as file descriptors, can <code>poll()</code> on them without blocking a thread, and the kernel only waits when told to. This suits event-driven compositors.</li>
</ul>
<p>Android's compositor works this way. Each frame has an acquire fence (the GPU is done drawing), and the display returns a present/release fence (the buffer can be reused).</p>
<h2>The Android frame lifecycle in one picture</h2>
<p>The app draws a frame and passes the buffer, with an <strong>acquire fence</strong>, down to the display driver through SurfaceFlinger and the Hardware Composer. The kernel waits for those fences, flips the display, then signals an <strong>out-fence</strong> that travels back up as the <strong>release fence</strong>, telling the app the buffer is safe to reuse. The kernel’s wait is where this bug lived: it must wait on <em>every</em> plane’s fence.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/1a5042bd-7592-4543-9efc-b108a72059cc.png" alt="" style="display:block;margin:0 auto" />

<h2>Following one commit through the kernel</h2>
<p>When userspace calls <code>drmModeAtomicCommit()</code> with an <code>IN_FENCE_FD</code>, the DRM core turns the file descriptor into a <code>dma_fence</code> and stores it in the plane state. The driver's <code>&lt;gpu&gt;_atomic_commit_fence_wait()</code> then walks every plane in the commit and waits on its fence before the hardware is programmed. Only after all of them signal does it flip the buffers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/aeac5cab-5dc4-4022-bb09-3862438a0c08.png" alt="" style="display:block;margin:0 auto" />

<h2>The bug</h2>
<p>The wait loop looked like this (simplified):</p>
<pre><code class="language-c">int ret, i;

for_each_new_plane_in_state(&amp;&lt;gpu&gt;_state-&gt;base, plane, new_plane_state, i) {
	if (new_plane_state-&gt;fence) {
		ret = dma_fence_wait_timeout(new_plane_state-&gt;fence, false, timeout);
		/* ... ret &lt; 0 is treated as an error, the remaining waits are skipped ... */
	}
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/c56c9f23-f6ce-4f7e-9693-ec5ab22bb987.png" alt="" style="display:block;margin:0 auto" />

<p><code>dma_fence_wait_timeout()</code> returns a <code>long</code>: the remaining timeout, or a negative error. For a fence that is <strong>already signaled</strong>, it returns immediately with the timeout value, here <code>MAX_SCHEDULE_TIMEOUT</code> (<code>0x7fffffffffffffff</code>).</p>
<p>The driver stored that in an <code>int</code>, so only the lower 32 bits survived: <code>0xFFFFFFFF</code>, which is <code>-1</code>. The code read this as an error and <strong>skipped the wait on the remaining fences</strong>.</p>
<p>So the failing recipe is narrow: one plane with an already-signaled (or stub) fence, plus another plane whose fence is still pending. The Android compositor produces exactly that, with stub fences on overlay planes and a real GPU fence on the composited layer. With only pending fences, or only signaled ones, nothing went wrong, which is why it hid for so long. The symptom was the GPU-composited layer being scanned out too early: visual artifacts.</p>
<h2>The fix</h2>
<p>The fix is one line of type, <a href="https://patchwork.freedesktop.org/patch/662982/">drm kernel patch</a> :</p>
<pre><code class="language-diff">-	int ret, i;
+	long ret;
+	int i;
</code></pre>
<p>It restores the full 64-bit return value, so an already-signaled fence is no longer mistaken for an error and the loop keeps waiting on the rest.</p>
<h2>The GPU test</h2>
<p><a href="https://gitlab.freedesktop.org/drm/igt-gpu-tools">GPU Tools</a> is the Linux kernel graphics test suite (IGT). I wanted a test that:</p>
<ol>
<li>needs no GPU rendering (so it is deterministic and runs anywhere), and</li>
<li>controls fence timing exactly.</li>
</ol>
<p>The trick is <code>sw_sync</code>, a kernel software fence facility. You create a timeline (a monotonically increasing counter), create fences at a sequence number, and signal them yourself by advancing the timeline.</p>
<p>A timeline starting at 0 behaves like this: a fence at point 1 is <em>unsignaled</em> while the timeline is 0, and becomes <em>signaled</em> the moment the timeline reaches 1.</p>
<h3>What the test does</h3>
<p>The subtest is <code>multiplane-atomic-fence-wait</code> in <code>tests/kms_explicit_fence.c</code>. It needs a CRTC with at least two overlay planes and skips otherwise.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aeab1f23a6a6a6be72f5d2d/13df870f-52e3-4efd-8514-3550251edee0.png" alt="" style="display:block;margin:0 auto" />

<ol>
<li><strong>Set up a modeset</strong> with three planes, each showing a solid color: primary = blue, overlay 1 = red, overlay 2 = green. No fences yet.</li>
<li><strong>Capture a baseline CRC</strong> ("before").</li>
<li><strong>For each of the three planes</strong>, create a <code>sw_sync</code> timeline and a fence at sequence point 1. Then:<ul>
<li><strong>primary and overlay 1:</strong> advance the timeline, so the fence is <strong>already signaled</strong>,</li>
<li><strong>overlay 2:</strong> leave the fence <strong>unsignaled</strong>, the long-running GPU job,</li>
<li>attach each fence to its plane as <code>IN_FENCE_FD</code>.</li>
</ul>
</li>
<li><strong>Swap the overlay framebuffers</strong> (red and green) so a scanout update would change the CRC.</li>
<li><strong>Request an</strong> <code>OUT_FENCE_PTR</code> on the CRTC and <strong>commit atomically, non-blocking</strong>. The commit must succeed immediately.</li>
<li><strong>Verify nothing has happened yet:</strong><ul>
<li>overlay 2's fence is still unsignaled,</li>
<li>the out-fence is <strong>not</strong> signaled,</li>
<li>after sleeping 100 ms, the out-fence is still not signaled and the <strong>CRC still equals the baseline</strong>.</li>
</ul>
</li>
<li><strong>Signal overlay 2's fence.</strong></li>
<li><strong>Wait for the out-fence</strong> (2 s timeout), then check the CRC <strong>differs</strong> from the baseline: the update happened, and only after the last fence.</li>
<li>Close the fences and timelines.</li>
</ol>
<p>A driver with this bug sees the two already-signaled fences, misreads the first return value as an error, and never waits for overlay 2. The display updates early, the out-fence signals, and the test fails at step 6 instead of letting the artifacts reach a real screen.</p>
<p>I have submitted the test upstream. It went through seven revisions on the dev mailing list and its merged now, with changes such as CRC checks instead of visual inspection, a vendor-neutral setup and a scalable overlay-plane check.</p>
<p>You can read the patch and the review thread here: <a href="https://gitlab.freedesktop.org/drm/igt-gpu-tools/-/commit/e007dbf296852307fbbb4c7d207105d11e5753e4?anubis_answer=orange">Add Explicit Fencing Tests</a></p>
<h2>Debugging tips</h2>
<ul>
<li><p>List plane properties: <code>cat /sys/kernel/debug/dri/0/i915_display_info | grep -A 20 "plane"</code></p>
</li>
<li><p>Enable fence tracing:</p>
<pre><code class="language-bash">echo 1 &gt; /sys/kernel/debug/tracing/events/dma_fence/enable
echo 1 &gt; /sys/kernel/debug/tracing/events/sync_timeline/enable
cat /sys/kernel/debug/tracing/trace_pipe
</code></pre>
</li>
<li><p>On Android: <code>dumpsys SurfaceFlinger --hwclayers</code></p>
</li>
</ul>
<h2>What I learned</h2>
<ul>
<li>A fix is not done until a test pins it down. Without a test, the same regression can return unnoticed.</li>
<li>Software fences (<code>sw_sync</code>) turn a timing-dependent GPU problem into a deterministic one.</li>
<li>Different userspace stacks exercise different kernel paths. A bug can be invisible on one platform and obvious on another.</li>
<li>Mind return types. A <code>long</code> stored in an <code>int</code> looked harmless for years, until a "very large but valid" value turned into <code>-1</code>.</li>
</ul>
<hr />
<p><em>Questions or feedback? I'd love to hear from you. I work on Linux graphics and GPU drivers.</em></p>
]]></content:encoded></item></channel></rss>