<?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[Aayush's Blog]]></title><description><![CDATA[Aayush's Blog]]></description><link>https://aayush0325.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 03 Sep 2026 10:18:30 GMT</lastBuildDate><atom:link href="https://aayush0325.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Deep Dive into Linux by Building xkey: ioctl, devices, uinput and device driver internals]]></title><description><![CDATA[Introduction
I started working on xkey project with a simple goal: make my keyboard act like a gamepad on Linux. I knew I could read key events from /dev/input/event*, and I had heard about uinput for]]></description><link>https://aayush0325.hashnode.dev/deep-dive-into-linux-by-building-xkey-ioctl-devices-uinput-and-device-driver-internals</link><guid isPermaLink="true">https://aayush0325.hashnode.dev/deep-dive-into-linux-by-building-xkey-ioctl-devices-uinput-and-device-driver-internals</guid><dc:creator><![CDATA[Aayush Khanna]]></dc:creator><pubDate>Sun, 05 Jul 2026 10:42:31 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>I started working on <a href="https://github.com/aayush0325/xkey">xkey</a> project with a simple goal: make my keyboard act like a gamepad on Linux. I knew I could read key events from <code>/dev/input/event*</code>, and I had heard about <code>uinput</code> for creating virtual input devices, but I did not really understand what happened between my user-space program and the kernel.</p>
<p>To make xkey work, I had to answer questions like:</p>
<ul>
<li><p>How does <code>ioctl(fd, EVIOCGRAB, 1)</code> steal the keyboard from the rest of the system?</p>
</li>
<li><p>How does writing to <code>/dev/uinput</code> make a new device appear in <code>/dev/input</code>?</p>
</li>
</ul>
<p>This post is the story of what I learned while digging through the Linux kernel source. It traces <code>ioctl</code> from user space, through the VFS dispatch layer, and into driver-specific handlers, using the input subsystem (<code>/dev/input/event*</code>) and uinput (<code>/dev/uinput</code>) as the running examples from xkey.</p>
<hr />
<h2>Everything Is a File: Including Your Keyboard</h2>
<p>I was already familiar with the Unix mantra: <strong>everything is a file</strong>. In Linux, hardware devices are exposed through the virtual filesystem as special files. This is not just a cute abstraction, it is the reason the same <code>open()</code>, <code>read()</code>, <code>write()</code>, and <code>close()</code> calls work on regular files, keyboards, mice, and virtual controllers.</p>
<p>When I list the devices on my laptop using <code>evtest</code>, I see entries like this:</p>
<pre><code class="language-text">Available input devices:
Event      Name                 Path
------------------------------------------------------------
17         HD-Audio Generic Headphone /dev/input/event17
16         HD-Audio Generic Mic /dev/input/event16
15         HDA NVidia HDMI/DP,pcm=9 /dev/input/event15
14         HDA NVidia HDMI/DP,pcm=8 /dev/input/event14
13         HDA NVidia HDMI/DP,pcm=7 /dev/input/event13
12         HDA NVidia HDMI/DP,pcm=3 /dev/input/event12
11         Ideapad extra buttons /dev/input/event11
10         ELAN06FA:00 04F3:327E Touchpad /dev/input/event10
9          ELAN06FA:00 04F3:327E Mouse /dev/input/event9
8          Video Bus            /dev/input/event8
7          ITE Tech. Inc. ITE Device(8176) Wireless Radio Control /dev/input/event7
6          Video Bus            /dev/input/event6
5          ITE Tech. Inc. ITE Device(8176) Keyboard /dev/input/event5
2          AT Translated Set 2 keyboard /dev/input/event2
1          Lid Switch           /dev/input/event1
0          Power Button         /dev/input/event0
</code></pre>
<p>Each row represents a real piece of hardware (or a virtual device) that the kernel has registered as a file. The kernel's device model creates these nodes so that user-space programs do not need to know the physical bus address or driver name they just need a path.</p>
<p>What makes a device file different from a regular file is the <strong>file operations table</strong> attached to it. When I call <code>open("/dev/input/event5", O_RDONLY)</code>, the kernel resolves the path to an <code>inode</code>, then to a <code>struct file *</code>, and finally to a <code>struct file_operations *</code> that tells the kernel what functions to call for <code>read</code>, <code>write</code>, <code>ioctl</code>, and so on.</p>
<p>This is the key insight: <strong>a device driver registers a table of callbacks, and the kernel uses that table to treat hardware as a file.</strong></p>
<hr />
<h2>The User-Space Call</h2>
<p>In xkey, the first <code>ioctl</code> I use is <code>EVIOCGRAB</code>:</p>
<pre><code class="language-c">#include &lt;sys/ioctl.h&gt;

int keyboard_fd = open("/dev/input/event5", O_RDONLY);
ioctl(keyboard_fd, EVIOCGRAB, 1);  // Grab exclusive access to the keyboard
</code></pre>
<p>At this point, the C library (glibc) wraps the call into a single system call. On x86_64, that becomes something like:</p>
<pre><code class="language-asm">mov eax, 16         # __NR_ioctl
mov edi, edi        # fd
mov esi, 0x40044518 # cmd (EVIOCGRAB = _IOW('E', 0x90, int))
mov edx, 1          # arg (integer 1, not a pointer here)
syscall
</code></pre>
<p>The argument <code>arg</code> is passed as an <code>unsigned long</code>. On 64-bit systems it usually holds a <strong>user-space pointer</strong>, but for simple commands like <code>EVIOCGRAB</code> it can be a literal integer. The driver decides how to interpret it.</p>
<hr />
<h2>Entering Kernel Space: <code>fs/ioctl.c</code></h2>
<p>All three arguments (<code>fd</code>, <code>cmd</code>, <code>arg</code>) arrive in the kernel's syscall entry code, which eventually lands in the C wrapper defined in <a href="https://elixir.bootlin.com/linux/v7.1.2/source/fs/ioctl.c"><code>fs/ioctl.c</code></a>.</p>
<h3>The Syscall Wrapper</h3>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/fs/ioctl.c#L583"><code>fs/ioctl.c:583</code></a>:</p>
<pre><code class="language-c">SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)
{
	CLASS(fd, f)(fd);
	int error;

	if (fd_empty(f))
		return -EBADF;

	error = security_file_ioctl(fd_file(f), cmd, arg);
	if (error)
		return error;

	error = do_vfs_ioctl(fd_file(f), fd, cmd, arg);
	if (error == -ENOIOCTLCMD)
		error = vfs_ioctl(fd_file(f), cmd, arg);

	return error;
}
</code></pre>
<p>This is just a thin wrapper. Its job is:</p>
<ol>
<li><p><strong>Resolve the</strong> <code>fd</code> → get a <code>struct fd</code> (file descriptor + reference count).</p>
</li>
<li><p><strong>Run LSM/security checks</strong> via <code>security_file_ioctl()</code>.</p>
</li>
<li><p><strong>Try common VFS ioctls first</strong> via <code>do_vfs_ioctl()</code>.</p>
</li>
<li><p><strong>Fall back to the driver's handler</strong> via <code>vfs_ioctl()</code> if the command is not a generic one.</p>
</li>
</ol>
<h3>The Driver Dispatch: <code>vfs_ioctl()</code></h3>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/fs/ioctl.c#L33"><code>fs/ioctl.c:33</code></a>:</p>
<pre><code class="language-c">static int vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
	int error = -ENOTTY;

	if (!filp-&gt;f_op-&gt;unlocked_ioctl)
		goto out;

	error = filp-&gt;f_op-&gt;unlocked_ioctl(filp, cmd, arg);
	if (error == -ENOIOCTLCMD)
		error = -ENOTTY;
 out:
	return error;
}
</code></pre>
<p>This is when things started clicking to me. <strong>The kernel does not know what ioctl does.</strong> It looks up the <code>file_operations</code> table associated with the open file and jumps to whatever function the driver registered under <code>unlocked_ioctl</code>.</p>
<p>The <code>ioctl</code> syscall is a <strong>generic dispatcher</strong>. The actual meaning of <code>EVIOCGRAB</code>, <code>UI_SET_EVBIT</code>, or <code>UI_DEV_CREATE</code> lives entirely in the driver.</p>
<hr />
<h2>The <code>file_operations</code> Table</h2>
<p>Every open file in Linux has a <code>struct file *</code>. That structure holds a pointer to the file's operations table:</p>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/include/linux/fs.h#L1926"><code>include/linux/fs.h:1926</code></a>:</p>
<pre><code class="language-c">struct file_operations {
	struct module *owner;
	fop_flags_t fop_flags;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
	ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
	int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
			unsigned int flags);
	///
}
</code></pre>
<h3>The Input Subsystem's Table</h3>
<p>For <code>/dev/input/event*</code> files, the table is defined in <a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/evdev.c#L1290"><code>drivers/input/evdev.c:1290</code></a>:</p>
<pre><code class="language-c">static const struct file_operations evdev_fops = {
	.owner		= THIS_MODULE,
	.read		= evdev_read,
	.write		= evdev_write,
	.poll		= evdev_poll,
	.open		= evdev_open,
	.release	= evdev_release,
	.unlocked_ioctl	= evdev_ioctl,
#ifdef CONFIG_COMPAT
	.compat_ioctl	= evdev_ioctl_compat,
#endif
	.fasync		= evdev_fasync,
};
</code></pre>
<p>When <code>ioctl(fd, EVIOCGRAB, 1)</code> is called on an event node, <code>f_op-&gt;unlocked_ioctl</code> points to <code>evdev_ioctl()</code>. This leads to a chain of function calls and ends at <code>evdev_do_ioctl()</code> defined at <a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/evdev.c#L1027"><code>drivers/input/evdev.c:1027</code></a> that has the function calls defined for different values of <code>cmd</code>, this is where the <code>EVIOCGRAB</code> call is finally handled.</p>
<pre><code class="language-c">static long evdev_do_ioctl(struct file *file, unsigned int cmd, svoid __user *p, int compat_mode)
{
	struct evdev_client *client = file-&gt;private_data;
	struct evdev *evdev = client-&gt;evdev;
	struct input_dev *dev = evdev-&gt;handle.dev;
	struct input_absinfo abs;
	struct input_mask mask;
	struct ff_effect effect;
	int __user *ip = (int __user *)p;
	unsigned int i, t, u, v;
	unsigned int size;
	int error;

	/* First we check for fixed-length commands */
	switch (cmd) {
	/// ...
	case EVIOCGRAB:
		if (p)
			return evdev_grab(evdev, client);
		else
			return evdev_ungrab(evdev, client);
	/// ...
	}
}
</code></pre>
<p>Key insight: <code>dev-&gt;grab</code> <strong>stores a pointer to the client that owns the device exclusively</strong>. This is how the kernel knows which process owns the device.</p>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/evdev.c#L28"><code>drivers/input/evdev.c:28</code></a>:</p>
<pre><code class="language-c">struct evdev {
	int open;
	struct input_handle handle;
	struct evdev_client __rcu *grab; // pointer to the client that owns the device
	struct list_head client_list;
	spinlock_t client_lock; /* protects client_list */
	struct mutex mutex;
	struct device dev;
	struct cdev cdev;
	bool exist;
};
</code></pre>
<h3>The uinput Table</h3>
<p>For <code>/dev/uinput</code>, the table is in <a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/misc/uinput.c#L1146"><code>drivers/input/misc/uinput.c:1146</code></a>:</p>
<pre><code class="language-c">static const struct file_operations uinput_fops = {
	.owner		= THIS_MODULE,
	.open		= uinput_open,
	.release	= uinput_release,
	.read		= uinput_read,
	.write		= uinput_write,
	.poll		= uinput_poll,
	.unlocked_ioctl	= uinput_ioctl,
#ifdef CONFIG_COMPAT
	.compat_ioctl	= uinput_compat_ioctl,
#endif
};
</code></pre>
<p>Here, the handler is <code>uinput_ioctl()</code> which calls into <code>uniput_ioctl_handler()</code> <a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/misc/uinput.c#L1109"><code>drivers/input/misc/uinput.c</code></a>.</p>
<p>Each driver defines its own semantics. The generic <code>fs/ioctl.c</code> is completely agnostic about what the command means.</p>
<hr />
<h2>Encoding the <code>cmd</code> Parameter</h2>
<p>The <code>cmd</code> (request) number is not an arbitrary integer. It is a carefully packed 32-bit value that encodes:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><strong>type</strong></td>
<td>Owner identifier — 'K'=kernel, 'E'=input, 'I'=uinput, 'H'=hwmon, etc.</td>
</tr>
<tr>
<td><strong>number</strong></td>
<td>Unique command number within this owner</td>
</tr>
<tr>
<td><strong>size</strong></td>
<td>Size of data being transferred (in bytes)</td>
</tr>
<tr>
<td><strong>kind</strong></td>
<td>Transfer direction: 0=none, 1=read, 2=write, 3=read+write</td>
</tr>
</tbody></table>
<h3>The Macros in <code>include/linux/ioctl.h</code></h3>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/include/uapi/asm-generic/ioctl.h"><code>include/uapi/asm-generic/ioctl.h</code></a>:</p>
<pre><code class="language-c">#define _IOC_NONE  0U
#define _IOC_WRITE 1U
#define _IOC_READ  2U

#define _IOC(dir,type,nr,size) \
    (((dir)  &lt;&lt; _IOC_DIRSHIFT) | \
     ((type) &lt;&lt; _IOC_TYPESHIFT) | \
     ((nr)   &lt;&lt; _IOC_NRSHIFT) | \
     ((size) &lt;&lt; _IOC_SIZESHIFT))

/*
 * Used to create numbers.
 *
 * NOTE: _IOW means userland is writing and kernel is reading. _IOR
 * means userland is reading and kernel is writing.
 */
#define _IO(type,nr)          _IOC(_IOC_NONE,(type),(nr),0)
#define _IOR(type,nr,size)    _IOC(_IOC_READ,(type),(nr),sizeof(size))
#define _IOW(type,nr,size)    _IOC(_IOC_WRITE,(type),(nr),sizeof(size))
#define _IOWR(type,nr,size)   _IOC(_IOC_READ|_IOC_WRITE,(type),(nr),sizeof(size))

/* used to decode ioctl numbers.. */
#define _IOC_DIR(nr)		(((nr) &gt;&gt; _IOC_DIRSHIFT) &amp; _IOC_DIRMASK)
#define _IOC_TYPE(nr)		(((nr) &gt;&gt; _IOC_TYPESHIFT) &amp; _IOC_TYPEMASK)
#define _IOC_NR(nr)		(((nr) &gt;&gt; _IOC_NRSHIFT) &amp; _IOC_NRMASK)
#define _IOC_SIZE(nr)		(((nr) &gt;&gt; _IOC_SIZESHIFT) &amp; _IOC_SIZEMASK)
</code></pre>
<p>So <code>EVIOCGRAB</code> expands to:</p>
<pre><code class="language-c">#define EVIOCGRAB		_IOW('E', 0x90, int)			/* Grab/Release device */
// = _IOC(_IOC_WRITE, 'E', 0x18, sizeof(int))
// = 0x40044518  (on little-endian)
</code></pre>
<h3>Why Size Matters</h3>
<p>The <code>size</code> field is used to validate user data. When the driver calls <code>copy_from_user()</code> or <code>copy_to_user()</code>, the encoded size tells the caller how much memory is involved.</p>
<h2>uinput: Creating a Virtual Device via <code>ioctl</code></h2>
<p>The second half of xkey creates a virtual Xbox 360 controller.</p>
<pre><code class="language-c">ioctl(xkey_fd, UI_SET_EVBIT, EV_KEY);
ioctl(xkey_fd, UI_SET_EVBIT, EV_ABS);
ioctl(xkey_fd, UI_DEV_SETUP, &amp;usetup);
ioctl(xkey_fd, UI_DEV_CREATE);
</code></pre>
<h3>input_ioctl_handler()</h3>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/misc/uinput.c#L909"><code>drivers/input/misc/uinput.c</code></a>:</p>
<pre><code class="language-c">static long uinput_ioctl_handler(struct file *file, unsigned int cmd, unsigned long arg, void __user *p)
{
	int retval;
	struct uinput_device *udev = file-&gt;private_data;
	struct uinput_ff_upload ff_up;
	struct uinput_ff_erase  ff_erase;
	struct uinput_request *req;
	char *phys;
	const char *name;
	unsigned int size;

	// ...

	switch (cmd) {
	/// ...
	case UI_SET_EVBIT:
		retval = uinput_set_bit(arg, evbit, EV_MAX);
		goto out;

	case UI_DEV_CREATE:
		retval = uinput_create_device(udev);
		goto out;

	case UI_DEV_SETUP:
		retval = uinput_dev_setup(udev, p);
		goto out;
	/// ...
	}
}
</code></pre>
<h3>UI_DEV_CREATE: Creating the Device Node</h3>
<p><a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/misc/uinput.c#L327"><code>drivers/input/misc/uinput.c:237</code></a>:</p>
<pre><code class="language-c">static int uinput_create_device(struct uinput_device *udev)
{
    struct input_dev *dev = ud-&gt;dev;
    int error, nslot;

	/// ...

    dev-&gt;event = uinput_dev_event;

	input_set_drvdata(udev-&gt;dev, udev);

	error = input_register_device(udev-&gt;dev);
	if (error)
		goto fail2;

	// ...
    return 0;
}
</code></pre>
<p>The <code>input_register_device()</code> function does a lot of heavy lifting from here:</p>
<ol>
<li><p>Assigns a minor number from the input subsystem's dynamic range.</p>
</li>
<li><p>Creates the device node (<code>/dev/input/eventN</code>).</p>
</li>
<li><p>Adds the device to the global input device list.</p>
</li>
<li><p>Makes the device visible to user-space tools like <code>libinput</code> and <code>evtest</code>.</p>
</li>
</ol>
<p>So when xkey calls <code>ioctl(xkey_fd, UI_DEV_CREATE)</code>, a new file appears in <code>/dev/input/</code>, and it behaves exactly like a real controller.</p>
<h3>Where Does /dev/uinput Itself Come From?</h3>
<p>One more question bothered me while writing xkey: if <code>UI_DEV_CREATE</code> creates the <em>virtual controller</em> node, what creates the <code>/dev/uinput</code> node that xkey opens in the first place? The answer is in the driver's own registration code at the bottom of <a href="https://elixir.bootlin.com/linux/v7.1.2/source/drivers/input/misc/uinput.c#L1155"><code>drivers/input/misc/uinput.c</code></a>:</p>
<pre><code class="language-c">static const struct file_operations uinput_fops = {
	.owner		= THIS_MODULE,
	.open		= uinput_open,
	.release	= uinput_release,
	.read		= uinput_read,
	.write		= uinput_write,
	.poll		= uinput_poll,
	.unlocked_ioctl	= uinput_ioctl,
#ifdef CONFIG_COMPAT
	.compat_ioctl	= uinput_compat_ioctl,
#endif
};

static struct miscdevice uinput_misc = {
	.fops		= &amp;uinput_fops,
	.minor		= UINPUT_MINOR,
	.name		= UINPUT_NAME,
};
module_misc_device(uinput_misc);
</code></pre>
<p>This is the piece that made the "everything is a file" idea concrete for me:</p>
<ol>
<li><p><strong>uinput_fops</strong> is the file operations table for <code>/dev/uinput</code>. It points <code>unlocked_ioctl</code> to <code>uinput_ioctl()</code>, which is how our <code>ioctl</code> calls from xkey reach the kernel.</p>
</li>
<li><p><strong>uinput_misc</strong> is a <code>miscdevice</code> structure. The misc subsystem is a simple way for drivers to register a single character device node without needing a dedicated major number.</p>
</li>
<li><p><strong>module_misc_device(uinput_misc)</strong> registers the device. When the uinput module loads, the kernel creates <code>/dev/uinput</code> with the registered minor number (<code>UINPUT_MINOR = 223</code>).</p>
</li>
</ol>
<p>So the full chain is:</p>
<pre><code class="language-plaintext">module_misc_device(uinput_misc)
        ↓
   /dev/uinput appears
        ↓
   xkey: open("/dev/uinput", O_WRONLY | O_NONBLOCK)
        ↓
   ioctl(xkey_fd, UI_DEV_CREATE)
        ↓
   input_register_device()
        ↓
   /dev/input/eventN (Microsoft X-Box 360 pad) appears
</code></pre>
<p>The same pattern is repeated across the kernel: a driver registers a <code>file_operations</code> table, the kernel exposes it as a file, and user space talks to hardware or in this case, creates new hardware through that file.</p>
<hr />
<h2>xkey in Action: Program Logs</h2>
<p>Here is what xkey looks like when it runs on my laptop. First it lists input devices, I select the keyboard, and then it prints raw key events.</p>
<pre><code class="language-text">Available input devices:
Event      Name                 Path
------------------------------------------------------------
17         HD-Audio Generic Headphone /dev/input/event17
16         HD-Audio Generic Mic /dev/input/event16
15         HDA NVidia HDMI/DP,pcm=9 /dev/input/event15
14         HDA NVidia HDMI/DP,pcm=8 /dev/input/event14
13         HDA NVidia HDMI/DP,pcm=7 /dev/input/event13
12         HDA NVidia HDMI/DP,pcm=3 /dev/input/event12
11         Ideapad extra buttons /dev/input/event11
10         ELAN06FA:00 04F3:327E Touchpad /dev/input/event10
9          ELAN06FA:00 04F3:327E Mouse /dev/input/event9
8          Video Bus            /dev/input/event8
7          ITE Tech. Inc. ITE Device(8176) Wireless Radio Control /dev/input/event7
6          Video Bus            /dev/input/event6
5          ITE Tech. Inc. ITE Device(8176) Keyboard /dev/input/event5
2          AT Translated Set 2 keyboard /dev/input/event2
1          Lid Switch           /dev/input/event1
0          Power Button         /dev/input/event0

Enter the event number for your keyboard: 5
RELEASE  code=0x001c (28)
--------SYN REPORT--------
PRESS  code=0x001e (30)
--------SYN REPORT--------
aRELEASE  code=0x001e (30)
--------SYN REPORT--------
PRESS  code=0x001f (31)
--------SYN REPORT--------
sPRESS  code=0x0020 (32)
--------SYN REPORT--------
dRELEASE  code=0x001f (31)
--------SYN REPORT--------
RELEASE  code=0x0020 (32)
--------SYN REPORT--------
</code></pre>
<p>I pressed <code>Enter</code> (code 28), then <code>A</code> (code 30), <code>S</code> (code 31), and <code>D</code> (code 32). Each key press and release is reported as an <code>EV_KEY</code> event, followed by an <code>EV_SYN/SYN_REPORT</code> event that marks the end of a batch.</p>
<p>When I press <code>Esc</code>, xkey toggles controller mode, grabs the keyboard, and starts writing translated events to <code>/dev/uinput</code>.</p>
<hr />
<h2>Verification with <code>evtest</code></h2>
<p>To prove that the virtual Xbox 360 controller actually exists and produces events, I run <code>evtest</code> on the new device node. After starting xkey and toggling controller mode, a new <code>/dev/input/eventN</code> node appears with the name <code>Microsoft X-Box 360 pad</code>.</p>
<p>When I press mapped keys, <code>evtest</code> shows the translated controller events. For example, pressing <code>W</code> and <code>A</code> together produces:</p>
<pre><code class="language-text">Event: time 1234567890.123456, type 3 (EV_ABS), code 0 (ABS_X), value -23170
Event: time 1234567890.123456, type 3 (EV_ABS), code 1 (ABS_Y), value -23170
Event: time 1234567890.123456, type 0 (EV_SYN), code 0 (SYN_REPORT), value 0
</code></pre>
<p>This confirms that the virtual device is registered correctly, the axis ranges match the setup values, and events flow from the physical keyboard through xkey into the kernel's input subsystem.</p>
<hr />
<h2>Conclusion</h2>
<p>The xkey project is small, but it touches the full stack: device nodes, the VFS, the input subsystem, and user/kernel data copying. Reading the kernel source for <code>fs/ioctl.c</code>, <code>drivers/input/evdev.c</code>, and <code>drivers/input/misc/uinput.c</code> turned a confusing API into a clear mental model. That is the most valuable thing I got out of it. And yes, it can run Doom.</p>
]]></content:encoded></item><item><title><![CDATA[A 35B Model, 6GB of VRAM, and a Lot of Systems Programming]]></title><description><![CDATA[Introduction
Hi guys, I've been working with SLMs recently and I wanted to share some thoughts and experiences from a rabbit hole I accidentally fell into.
For context, I'm really into systems program]]></description><link>https://aayush0325.hashnode.dev/a-35b-model-6gb-of-vram-and-a-lot-of-systems-programming</link><guid isPermaLink="true">https://aayush0325.hashnode.dev/a-35b-model-6gb-of-vram-and-a-lot-of-systems-programming</guid><dc:creator><![CDATA[Aayush Khanna]]></dc:creator><pubDate>Sat, 20 Jun 2026 22:26:02 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>Hi guys, I've been working with SLMs recently and I wanted to share some thoughts and experiences from a rabbit hole I accidentally fell into.</p>
<p>For context, I'm really into systems programming, and my daily driver is a fairly modest machine with 24 GB RAM, an NVIDIA RTX 4050 with 6 GB of VRAM, and an AMD Ryzen 7 processor.</p>
<h2>From Ollama to llama.cpp</h2>
<p>Like most developers getting into local AI, my first choice was Ollama. It's incredibly easy to get started with and does a great job of making local models accessible. But after using it for a while, I found myself wanting more observablity and control. I wanted to understand where my memory was going, how layers were being offloaded, what was sitting in VRAM versus system RAM, and why performance changed so dramatically between different configurations. Ollama abstracts most of that away, which is great for usability, but I wanted to go beyond the abstraction.</p>
<p>That curiosity eventually led me to llama.cpp. What started as a search for a more customizable inference engine quickly turned into an exercise in systems programming. Being able to compile custom binaries, inspect memory allocations, benchmark different runtime configurations, and observe exactly what the model was doing made a huge difference. Instead of treating the model as a black box, I could see every tradeoff being made underneath.</p>
<h2>The Challenge: Qwen 3.6 35B-A3B MoE on 6 GB VRAM</h2>
<p>At some point I decided to see if I could run a Q4_K_M quantized version of Qwen 3.6 35B-A3B MoE locally. On paper, that sounds ridiculous for a laptop with only 6 GB of VRAM. While that would generally be true for traditional dense models, MoE architectures change the equation. The model contains 35 billion parameters, yet only around 3 billion are active for a given token. Once I started understanding how the architecture worked, the challenge stopped being about model size and became a question of data movement and memory management.</p>
<h3>The naive approach fails fast</h3>
<p>Initially, I offloaded most layers to the GPU and left the rest on the CPU as it made the most sense to do so. The results were terrible, with generation speeds hovering around 3 tokens per second. At first I assumed the GPU simply wasn't powerful enough, but the utilization numbers didn't support that conclusion. After digging deeper, I realized I was bottlenecked by PCIe transfers. Because Qwen 3.6 is a MoE model, the active experts and shared layers were spread across different devices, creating significant CPU to GPU communication overhead. The model was spending more time moving data than generating tokens.</p>
<h3>The fixes that moved the needle</h3>
<p>After a lot of experimentation, benchmarking, and failed configurations, I found a setup that dramatically improved performance. By keeping the MoE experts pinned in system RAM while letting the GPU handle the shared layers, generation speed increased from roughly 3 tokens per second to around 15 tokens per second. The improvement came entirely from understanding how data was flowing through the system and reducing unnecessary transfers.</p>
<p>I also enabled Flash Attention (<code>--flash-attn on</code>) and experimented with TurboQuant KV cache compression (<code>--cache-type-k turbo4</code>, <code>--cache-type-v turbo3</code> — 4-bit keys, 3-bit values) by using a fork of llama.cpp maintained by TheTom. The latter ended up being one of the most impressive optimizations I came across. It made context lengths that would otherwise be impossible fit comfortably within the hardware constraints. It's worth thinking about how different "fitting" and "performing well" really are.</p>
<h2>The Final Configuration</h2>
<p>After all the experimentation, here is the <code>llama-server</code> launch command I settled on, running the Q4_K_M quant of <code>bartowski/Qwen_Qwen3.6-35B-A3B-GGUF</code>:</p>
<pre><code class="language-shell">llama-server \
    --model Qwen_Qwen3.6-35B-A3B-Q4_K_M.gguf \
    --port 8080 \
    --host 0.0.0.0 \
    --ctx-size 262144 \
    --cache-type-k turbo4 \
    --cache-type-v turbo3 \
    --n-gpu-layers 999 \
    --n-cpu-moe 41 \
    --reasoning on \
    --reasoning-format deepseek \
    --reasoning-budget -1 \
    --parallel 1 \
    --flash-attn on
</code></pre>
<p>A quick walkthrough of what each flag is doing:</p>
<ul>
<li><p><code>--ctx-size 262144</code>: Allocates the full 262K context window. TurboQuant is what makes this fit in 6 GB VRAM; without it, you'd be hard-capped at a fraction of this.</p>
</li>
<li><p><code>--cache-type-k turbo4</code> / <code>--cache-type-v turbo3</code>: 4-bit keys and 3-bit values on the KV cache. Near-lossless compression from Google DeepMind's technique, the single biggest reason 262K context is reachable at all.</p>
</li>
<li><p><code>--n-gpu-layers 999</code>: Pulls everything possible onto the GPU. Combined with the next flag, this means shared/base layers live in VRAM while the experts stay in system RAM.</p>
</li>
<li><p><code>--n-cpu-moe 41</code>: Pins the expert weights of the first 41 layers to system RAM instead of VRAM. This is the floor on a 6 GB card, lowering it to push more experts onto the GPU OOMs once the KV cache needs room. This was the change that took generation from ~3 tok/s to ~15 tok/s.</p>
</li>
<li><p><code>--reasoning on</code> / <code>--reasoning-format deepseek</code> / <code>--reasoning-budget -1</code>: Enables chain-of-thought output, rendered into <code>message.reasoning_content</code> using the DeepSeek-style format. The <code>-1</code> budget lets the model decide how much reasoning to emit per request.</p>
</li>
<li><p><code>--parallel 1</code>: Keeps a single request slot on the server. This prevents context collisions when the frontend fires retry or prefill requests in parallel.</p>
</li>
<li><p><code>--flash-attn on</code>: Reduces the memory and compute cost of attention layers — a real speedup and VRAM saving that compounds as the context window grows.</p>
</li>
</ul>
<h2>Benchmarks</h2>
<p>To quantify this, I benchmarked the final configuration using <code>llama-bench</code> with five runs per test, <code>--n-cpu-moe 41</code>, and no <code>--no-mmap</code>.</p>
<table>
<thead>
<tr>
<th>Context (depth)</th>
<th>tok/s (tg128)</th>
</tr>
</thead>
<tbody><tr>
<td>512</td>
<td>31.24 ± 1.61</td>
</tr>
<tr>
<td>2,048</td>
<td>31.79 ± 0.44</td>
</tr>
<tr>
<td>8,192</td>
<td>28.11 ± 0.52</td>
</tr>
<tr>
<td>32,768</td>
<td>18.93 ± 0.12</td>
</tr>
<tr>
<td>131,072</td>
<td>7.47 ± 0.20</td>
</tr>
</tbody></table>
<p>At a context length of 512 tokens, the model generated at approximately 31.2 tokens per second. At 2K context it was still around 31.8 tokens per second, and at 8K context it remained a very usable 28.1 tokens per second. Even at 32K context the model was producing roughly 18.9 tokens per second, which feels perfectly comfortable for interactive work.</p>
<p>The interesting part comes when you start pushing truly massive contexts. At 131K tokens, throughput drops to around 7.5 tokens per second. At the full 262K context window, generation falls to approximately 3.5 tokens per second. The model can technically handle the context, and TurboQuant makes it fit into memory, but the computational cost doesn't disappear. Large context windows are possible, but they are definitely not free.</p>
<h2>The --no-mmap Tradeoff</h2>
<p>The rabbit hole got even deeper when I started investigating memory mapping. A single runtime flag ended up teaching me more about operating system behavior than I expected. With memory mapping enabled, RAM usage stayed lower because the operating system could page model weights in as needed. The downside was that as context lengths grew larger, page faults became increasingly visible in latency. Disabling memory mapping forced the model weights into RAM and significantly improved responsiveness, but now the model was consuming over twenty gigabytes of memory. The machine became noticeably less pleasant to use for anything else.</p>
<h2>CLI Tools: SLMs, Prompts, and Agent Design</h2>
<p>To actually use the model with a harness I went ahead with opencode, but it didn't work nearly as well as I expected. Probably because it has large system prompts bundled into the agent which consumes a significant portion of the model's reasoning budget, which smaller models struggle to handle. The responses felt inconsistent and the models often lost track of the task.</p>
<p>To counter this I switched to a much more minimal agent setup using pi. The difference was surprisingly noticeable. Simpler prompts, less orchestration overhead, and the model stayed focused for much longer. It was a good reminder that agent design matters just as much as model size.</p>
<h2>Web Chat Interface: Open WebUI</h2>
<p>Running the server with a single request slot also eliminated a number of strange context issues caused by overlapping requests and retries from the frontend. At this point my setup is fairly simple: one terminal running llama-server, another running Open WebUI, and a browser tab pointed at localhost. It's become a surprisingly comfortable daily-driver workflow.</p>
<h2>Who Is This Actually Useful For?</h2>
<p>I can see this being used for low-risk, high-impact tasks like document summarization, scripting, working with small codebases, debugging configs, and generating initial project scaffolding. Anyone who knows what they're doing can get a lot out of smaller models like these.</p>
<h2>Closing Thoughts</h2>
<p>The whole process was really fun as I started out trying to run a large language model on a laptop and ended up applying concepts from operating systems, memory management, hardware bottlenecks, and performance engineering instead. It's really interesting how AI is not just a machine learning problem anymore but also a distributed systems problem, a compiler optimization problem, a hardware problem, an enterprise software problem and a product problem at the same time.</p>
]]></content:encoded></item></channel></rss>