VulPy
Write Python. Run on any GPU. Inspect the GLSL it generates.
VulPy is a Vulkan compute runtime with a Python front end. You write Python, with access to any architectural pattern the language supports, and VulPy's AST transpiler builds it into GLSL, compiles to SPIR-V, and dispatches on whatever Vulkan can reach: an RTX 4090, an Intel Iris Xe, a phone's Adreno 730 in Termux. No CUDA, no vendor lock-in, no separate backends.
The generated GLSL is always inspectable. If you want to see what a persistent kernel looks like explicitly in shader code, or what a zero-copy memory channel compiles to, it's right there, readable and modifiable. VulPy can be a prototyping layer you eventually leave, or a runtime you stay in. Either way you end up understanding what your code actually does on the GPU.
CuPy had the right idea making GPU compute feel like NumPy. VulPy follows the same instinct but targets Vulkan. And because GLSL and SPIR-V are already the common language every major vendor builds tooling around — the one GPU IR whose drivers stay maintained on every platform because the games industry funds them — the output isn't just inspectable, it's portable twice over. Share the .vul.py file and anyone with pip install vulpy runs it on whatever Vulkan device they have. Or run python -m vulpy emit-glsl kernel.vul.py and share the GLSL itself: readable, compilable, zero VulPy dependency. Working in an Nvidia shop? Transliterate to CUDA. AMD? ROCm. The generated shader is a working starting point, not a blank page.
import numpy as np
import vulpy as vp
x = np.linspace(0.0, 1.0, 1_000_000, dtype=np.float32)
y = vp.sin(x) + vp.exp(x) # GPU-accelerated, returns NumPy array
Install
pip install vulpy
Requires glslangValidator on PATH for runtime GLSL → SPIR-V compilation.
Linux (Ubuntu / Debian)
sudo apt install glslang-tools
pip install vulpy
Android / Termux
pkg install python-numpy glslang
pip install vulkan vulpy vulkan-tools
pkg uninstall vulkan-loader-generic
pkg install vulkan-loader-android
Use pkg install python-numpy rather than pip install numpy, because pip throws a fit and crashes building the wheel on android.
macOS / Windows
Expected to work via MoltenVK (macOS) or the LunarG Vulkan SDK (Windows), but I don't have a mac and I haven't run Windows in a year. Please let me know if you get it working, the steps you took, and if anything breaks.
Tested Hardware
| Device | GPU | Platform |
|---|---|---|
| Laptop | NVIDIA RTX 4050 Mobile | Ubuntu Linux |
| Laptop | Intel Iris Xe (iGPU, same machine) | Ubuntu Linux |
| Vivo X Fold | Adreno 730 | Android / Termux |
| Nubia RedMagic 8 | Adreno 740 | Android / Termux |
Any device with Vulkan compute support should work. Android phones don't need root, just Termux, Python, and the correct Vulkan driver package.
Memory Model
Currently, VulPy exposes four memory kinds, corresponding directly to Vulkan memory property flags:
| Kind | GPU speed | CPU readable | Use for |
|---|---|---|---|
"device" |
Full VRAM bandwidth | No | Large working arrays, anything the GPU owns entirely |
"host" |
PCIe bandwidth | Yes (write-combining — fast write, slow read) | Staging, control buffers, write-only channels |
"cached" |
PCIe bandwidth | Yes (full DDR bandwidth) | Read-back, inter-GPU data bridges |
"rebar" |
Full VRAM bandwidth | Yes (limited) | Small buffers that need occasional CPU access |
On integrated GPUs and phones (Unified Memory Architecture), "device" and "host" collapse to the same physical DRAM. The CPU and GPU are on the same die sharing the same memory bus; the distinction disappears at the hardware level.
Zero-copy: one dataset, multiple devices
VulPy can allocate frame buffers backed by page-aligned host DRAM and import those physical pages into multiple GPU address spaces simultaneously via VK_EXT_external_memory_host. The GPU can write directly to system DRAM. A NumPy array pointing at the same pages can then see the result immediately. No copy, no transfer, no staging buffer.
For instance, on a system with both a discrete GPU and an integrated GPU:
- The dGPU can run compute against its VRAM-resident buffers at full GPU memory bandwidth
- A dedicated workgroup can then write selected results to the shared host pages, once, over PCIe
- The iGPU can import those same physical pages into its own address space and run against them
- A NumPy view on the CPU reads from those same DRAM cells
- Anything else on the CPU reads from that NumPy view
One write. One dataset. Three concurrent possible readers. In persistent mode there are no dispatch signals, no shader reloads, no handshakes. The only traffic on the PCIe bus is data you explicitly committed. That's why the transfer is zero cost in the "persistent shader" architecture outlined below and in the tutorials.
On UMA hardware, step 2 is a same-die write, so PCIe doesn't exist to cross.
Features
VulPy has several features ranging from drop-in NumPy-like statements to full persistent GPU-side programs. The goal is to provide several ways to interact with your graphics cards, ranging from in-line VulPy statements to entire .vul.py kernels.
Convenience mode
Accepts NumPy arrays, returns NumPy arrays. No setup required.
x = np.linspace(0.0, 1.0, 1_000_000, dtype=np.float32)
y = vp.sin(x) + vp.exp(x)
Resident mode
Keeps arrays in Vulkan-owned memory across dispatches. numpy_view() returns a NumPy array whose data pointer is the persistently mapped address of the Vulkan allocation. Same physical bytes, no copy.
xv = vp.asarray(x)
yv = vp.empty_like(xv)
vp.sin(xv, out=yv)
result = yv.numpy_view() # NumPy data pointer == Vulkan buffer pointer
Fusion mode
vp.jit traces a Python lambda and emits one shader for the whole expression.
f = vp.jit(lambda a, b: vp.where(a > b, vp.sin(a), vp.cos(b)))
out = f(xv, xv * 0.5)
Kernel decorator mode
Write a kernel as an annotated Python function. VulPy transpiles the body to GLSL.
@vp.kernel(local_size_x=64)
def push_particles(
pos: vp.Buffer[vp.vec4],
vel: vp.Buffer[vp.vec4],
n: vp.uint32,
dt: vp.float32,
):
i = vp.global_id()
x = vp.var(pos[i].xyz)
v = vp.var(vel[i].xyz)
x[:] = x + v * dt
pos[i] = vp.vec4(x, pos[i].w)
push_particles(pos_v, vel_v, dispatch=N, n=N, dt=0.001)
.vul.py source mode
Write GPU source in a .vul.py file. Python syntax compiled to GLSL/SPIR-V by VulPy's AST transpiler. The generated shader is always inspectable.
# my_kernel.vul.py
import vulpy as vp
class PushConstants:
N: uint32
dt: float32
def main(pos: vp.Buffer[vp.float64], vel: vp.Buffer[vp.float64]):
i: uint32 = vp.global_id()
if i >= pc.N:
return
pos[i] = pos[i] + vel[i] * pc.dt
shader = vp.compile("my_kernel.vul.py", local_size_x=256, context=ctx)
shader.allocate(pos=pos_array, vel=vel_array) # sizes the buffers — no data moves
shader.upload("pos", pos_array)
shader.upload("vel", vel_array)
shader.run(dispatch=N, N=N, dt=0.001)
# inspect the shader
python -m vulpy emit-glsl my_kernel.vul.py
Raw GLSL mode
Pass handwritten GLSL source directly for full manual control.
k = vp.kernel(
GLSL_SOURCE,
buffers=2,
push=[("n", "uint32"), ("dt", "float32")],
)
k(pos, vel, dispatch=N, n=N, dt=0.001)
Control-buffer mode
Resident uint32 buffers that both CPU and GPU can read and write atomically. Use for flags, counters, GPU-written event queues, and adaptive kernel control.
control = vp.ControlBuffer(["mode", "hot_count"], words=16)
control["hot_count"] = 0
# GPU kernel: atomicAdd(control[hot_count], 1)
print(control["hot_count"])
Memory Sockets (MemSock)
A typed, directional memory channel between GPU and CPU. WebSocket-like semantics for data transfer between VRAM and DRAM.
This is the answer to a problem the persistent model creates: when one dispatch runs for seconds or hours, the natural observation points of launch-by-launch code disappear and the run becomes a black box you wait on. Getting sight back normally means hand-rolling pinned host memory, a ring layout, and a commit protocol; MemSock packages that machinery as a first-class object. Paired with the control buffer it works in both directions — stream meaning out, write intent in — so a long-running kernel can be watched live and steered mid-flight without ever stopping it.
# host .py
pipe = vp.MemSock(
dtype = vp.float32,
frame_shape = (ny, nx),
payload = {"snap_idx": vp.uint32},
)
@pipe.on("message")
def on_frame(frame, idx):
imshow.set_data(frame) # frame is a zero-copy numpy view into host DRAM
pipe.open(ctx, capacity=n_snapshots, ctrl_view=ctrl_arr, ctrl_offset=CTRL_PIPE)
# .vul.py — coordinator side
def pipe_body(snap_idx: vp.uint32):
pipe[snap_idx * pc.nx + p] = efield[p]
if t % pc.snap_interval == 0:
pipe.fire(pipe_body, snap_idx=t) # transpiler wraps this in the epoch protocol
For a full working example including iGPU hot-cell overlay, see Lesson 11: 3D FDTD Scatter.
Persistent GPU-side programs
A single kernel dispatch that runs like an application on your GPU, managing its own scheduling on-device. The CPU launches it, and then begins an async event loop that listens and reacts to events emitted by the GPU over MemSocks.
The reference implementation is the 3D FDTD tutorial, but the pattern applies broadly: long-running simulations, adaptive solvers, live visualization, and any workload where the GPU should own its compute loop rather than being called one dispatch at a time.
These are established techniques — persistent threads, software work queues, GPU-initiated I/O — the same architecture as D3D12 Work Graphs and the persistent megakernels in modern LLM inference engines, usually learned from research papers and production kernels. The tutorial series teaches them as a ladder of runnable steps and maps each piece to its literature.
Device Selection
vp.list_devices() # list all Vulkan devices
ctx = vp.Context() # default device
ctx = vp.Context(device_index=1) # by index
ctx = vp.Context(kind="dgpu") # discrete GPU
ctx = vp.Context(kind="igpu") # integrated GPU
Requirements
- Python ≥ 3.11
- NumPy ≥ 1.24
vulkanPython packageglslangValidatoron PATH- A Vulkan-compatible GPU with Vulkan drivers installed
Documentation
- API Reference
- Architecture
- Tutorial Series — 12 lessons from no-kernel array math to a 3D FDTD simulation demonstrating the persistent shader architecture, one new concept per lesson; stop at any rung and you have a complete toolkit
- Hardware and Platform Notes
Current Limitations
- Main convenience-mode compute path is
float32.float64is available in.vul.pykernels when the device reportsshaderFloat64support (desktop GPUs; not phones). - Resident outputs must be C-contiguous.
- Axis-aware reductions, fancy indexing, and bool dtype are not implemented.
- macOS and Windows are untested.
Metadata
Release files for vulpy 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| vulpy-0.1.0.tar.gz | 107.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vulpy-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 223.3 kB
Release files / vulpy-0.1.0.tar.gz
| Download URL | vulpy-0.1.0.tar.gz |
|---|---|
| Size | 107.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cf982100bded952e79acfdc0e039b5bbd125cc2e1309be9e3ef21d72a93babeb
|
|
BLAKE2b-256 checksum How to use checksums |
609827f00f3b58f668e242d5e6e62190010404a4a44fcb65ab3a0c15cc550bcf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / vulpy-0.1.0-py3-none-any.whl
| Download URL | vulpy-0.1.0-py3-none-any.whl |
|---|---|
| Size | 115.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e48575f0b53f1ca7eee646b293323b4f1a85e0b4b89eadb2c84e248c95e9574
|
|
BLAKE2b-256 checksum How to use checksums |
89f6721b87d7ee0f34a3549a8ca90a80576ea2217e9a9e910d31c948782c9d37
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|