编写你自己的内核优化器
Write your own kernel optimiser

原始链接: https://blog.csongor.co.uk/cachet-grids-and-tiles/

Cachet 是一种用于显式性能推理的语言,它将计算内容与计算方式分离。程序被定义为收缩(contractions),而调度通过经检查的转换来优化它们。编译器验证每个转换是否保持结果,或在假设失败时报告编译错误。借助矩阵乘法,本文通过循环交换、分块、打包、缓存放置、向量化、并行和矩阵指令推导出 CPU 与 GPU 内核,速度接近 OpenBLAS、Accelerate 和 MLX。它还展示了注意力机制,其中归约只能在显式许可下重新分组,从而产生用于精确浮点比较的影子参考。Cachet 暴露硬件细节,如缓存驻留、流量预算和指令模板,同时信任声明的库定律与平台假设。总体而言,它旨在让程序员控制优化,而无需隐藏的编译器决策。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 编写你自己的内核优化器 ( csongor.co.uk ) 6 分 由 speckx 1 小时前 | 隐藏 | 过去 | 收藏 | 讨论 帮助 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

Optimising a program is done in one of two ways. The first is to write down a program that computes the result we want, and hope that the compiler is smart enough to make it fast. The second, when that isn’t enough, is to rewrite it by hand, so that its data flow matches the hardware. This is where abstractions break down. When the compiler gives up on something written with a nice Python library, we end up writing custom kernels, intrinsics, and inline assembly.

Both rely on transformations that preserve meaning only under conditions, such as the output not overlapping the inputs. Compiler optimisations let us keep our original program, but hide the transformations. Hand-optimised code shows the result of the transformations, but hides where we started from.

In both cases, the decisions that matter are made somewhere we can’t see: for example, which block has to stay in cache, or which loops can be vectorised. An optimising compiler makes these decisions based on a model of the hardware that’s hidden from the source language. In hand-optimised code, we keep these in our head, or in comments in the source file.

When performance matters, I would rather not have a clever compiler. Instead, I want the compiler to be explicit. That’s the idea behind Cachet, a language I’ve been building to help reason about performance. In Cachet, which cache a buffer stays in, how much data moves between memory levels, and which instructions the hardware provides are part of the language. We can then make informed decisions, and have the compiler check our assumptions. If an assumption stops holding as the program evolves, it becomes a compile error rather than a performance regression.

This post is about the transformations themselves. In Cachet, we write down what to compute and, separately, how to compute it. A sequence of transformations, a schedule, turns the computation into an optimised program. The compiler then checks that each transformation preserves the meaning of the original.1

We’ll use matrix multiplication as a running example. Starting from its one-line definition, we’ll derive cache-blocked CPU kernels, and optimised GPU kernels using matrix hardware. I’ve run these examples on my laptop (with an M4 Max), but nothing in the technique is specific to Apple’s hardware: Cachet already checks programs for NVIDIA’s H100, though it can’t build them for it yet.

To see how we’re doing, we’ll compare each schedule with an optimised library implementation on the same hardware: OpenBLAS for the CPU’s vector instructions, Apple’s Accelerate for its matrix units, and MLX for the GPU. The little bars along the way show how close we get to the library’s speed, with the library at 100%. Each comparison uses the same matrix size and precision.

Matrix multiplication by hand

Matrix multiplication is very easy to do correctly, but notoriously difficult to do quickly. The definition fits on one line:

c[i, j] = sum k. a[i, k] * b[k, j]

A machine needs a little more handholding. For an M × K matrix multiplied by a K × N matrix, we can spell out the order of operations with three nested loops:

c// c starts out as all zeroes
for (int i = 0; i < M; i++)
  for (int j = 0; j < N; j++)
    for (int k = 0; k < K; k++)
      c[i][j] += a[i][k] * b[k][j];

This loop nest reaches just 1.17 GFLOP/s2 on one core for 1024 × 1024 matrices: about 1% of OpenBLAS’s speed on the same core. There’s a lot of room to improve:

Naive loop nest

1.17 GFLOP/s · 1,843.33 ms1.1% of OpenBLAS's speed

100% = OpenBLAS · CPU · one core · 108 GFLOP/s

CPU · one core · 1024 × 1024 · f32

The colours show how often each variable changes: i the least, j more often, and k on every multiplication. The hottest variable controls how the inner loop moves through the arrays. Here, k walks along a row of a, but down a column of b.

The arrays are stored row by row: b[0][0], b[0][1], and so on, followed by the next row. This is called row-major order. The processor doesn’t fetch single values. When a value isn’t in its cache (the small, fast memory close to the core), it fetches a whole chunk of neighbouring bytes called a cache line. Reading those neighbours can then use the cached copy, avoiding another trip to main memory.

Walking along a row uses those neighbours straight away. Walking down a column jumps a whole row at a time, and the cache may have replaced a line by the time we return to its other values. Here’s a small model of the two access patterns:

Each row of squares is a row of b, and each group of four is one cache line. In this model, a cached read takes 1 tick and a miss takes 8 ticks. Reads run one at a time, without prefetching.

Time 0

Along the rowsinner loop for j

The next read is the next element.

Down the columnsinner loop for k

The next read is a whole row ahead.

This cache model holds 4 lines and evicts the least recently used line, so a column of 8 pushes out each line before the walk returns to it.

We can get the row-wise access pattern just by swapping the two inner loops:

c// c starts out as all zeroes
for (int i = 0; i < M; i++)
  for (int k = 0; k < K; k++)
    for (int j = 0; j < N; j++)
      c[i][j] += a[i][k] * b[k][j];

The hottest variable is now j, the last subscript of b and c, so the inner loop walks along their rows, using neighbouring values while their line is still cached. (a[i][k] stays the same throughout that loop.) For large matrices, this makes a big difference:

Swapped loops

33 GFLOP/s · 65.64 ms30% of OpenBLAS's speed

100% = OpenBLAS · CPU · one core · 108 GFLOP/s

CPU · one core · 1024 × 1024 · f32

Swapping the loops makes the same multiplication about 28 times faster, though it’s still only 30% of OpenBLAS’s speed on one core.

An astute reader might notice that we still walk through all of B for every row of A. If B is too large to stay in cache, much of it has to be fetched again on each pass. Blocking, which we’ll get to shortly, keeps a block of inputs in cache until we’ve done as much work with it as possible.

Swapping loops doesn’t preserve a program’s meaning in general, though. Here, provided c doesn’t overlap either input, different values of j update different outputs. Each output still receives the same products in ascending order of k; we’ve only changed how updates to different outputs are interleaved. These conditions aren’t written down, and a fast kernel has many such rewrites stacked on top of each other.

We’d like the compiler to check these conditions for us.

Say what (not how)

Loops make this harder than it needs to be. They commit us to visiting the outputs in a particular sequence, even though each output can be computed independently. Before changing that sequence, we have to establish which parts of the order matter, which is busywork for either the programmer or the compiler. Finding constructs that describe the computation itself, and still map well to the hardware, is a language design problem.3

We can now write the definition as a Cachet function, starting with ordinary 32-bit floats:

cachetcontraction matmul
    (reads a: [f32; M, K])
    (reads b: [f32; K, N])
    -> out c: [f32; M, N] =
  c[i, j] = sum k. a[i, k] * b[k, j]

This is a contraction: a function defined by an equation for each output element. The body is the formula we started with. [f32; M, K] is an array with M rows and K columns, and reads lets the function read it. -> out c names the result: the caller provides space for c, and matmul fills it.

M, K, and N are the dimensions. The equation tells us which outputs are independent, which inputs to add up, and which operations to use:

  • i and j appear on the left, so they are free: each pair names one element of c, which is written exactly once. Different pairs compute independent outputs.
  • k only appears under the sum, so it’s summed: for each output, we multiply the pairs of inputs it selects and add up the products.
  • * and + come from the element type. Here that’s f32, but we’ll see later that the same formula works when an element is a block (another matrix).

Since the free indices say nothing about order, the outputs can be computed in any order, or all at once.

Rewriting the loop nest

A contraction has a precise meaning as a loop nest. You can ask Cachet’s compiler, cachetc, to print it with cachetc scheduled.

A schedule is a sequence of rewrites that turns this loop nest into another function. The compiler applies the rewrites once, when it builds the program. Calling the resulting function just runs the kernel.

The swap we already know comes first. The schedule is above the loop body it produces. Use Previous and Next to follow the rewrites; the current step is highlighted. ... leaves out code that isn’t relevant to the step:

schedule swapped of matmul =
  zero (i, j) c
  hoist (j, k) c
for i in 0 .. M do
  for j in 0 .. N do
    var acc'0: f32 = -0.0
    for k in 0 .. K do
      acc'0 = fma (load a[i, k]) (load b[k, j]) acc'0
    store c[i, j] = acc'0

Each output adds up its products in ascending order of k, starting from zero.

1.17 GFLOP/s · 1,843.33 ms1.1% of OpenBLAS's speed

100% = OpenBLAS · CPU · one core · 108 GFLOP/s

CPU · 1024 × 1024 · f32

In the starting loop nest, load reads a value and store writes one. The running total, acc'0, starts from zero, written -0.0 for a reason the semiring panel explains, and is stored once at the end. Each step uses a fused multiply-add: fma a b acc computes a * b + acc with a single rounding, rather than rounding the product separately.

This is the behaviour a schedule must preserve: each output receives the same chain of multiply-adds, in ascending order of k.

There’s a small problem with swapping the loops as they are. Each output keeps its running total in a local variable, which is stored once at the end. After the swap, we’d leave that output to work on the next one, then return to it at the next value of k. Its total has to survive between visits.

The first step, zero (i, j) c, prepares for this. It writes zero to every element of c first, in a loop of its own, and then has each output’s sum start from what its cell holds. The listing shows this sum as updates to the cell: you can read it as load the total, add a product, store it back. Each total still starts from zero and receives the same products in the same order.

Now hoist (j, k) c moves the steps of the sum outside the loop over j. Each visit loads a cell’s total, adds the next product, and stores it back.

After both steps, the line at the bottom summarises the schedule’s claim: what the compiler has checked. Here, swapped and matmul’s own loop nest produce identical results, bit for bit, for any inputs that satisfy the function’s requirements. The resulting function takes the same arrays as matmul, so we call it as swapped a b c.

The hoist we used for matmul is a step function from the cachet:steps library: a rewrite with a proof recipe. Its definition gives a piece of program to match, its replacement, and instructions for checking that the result is preserved. Every time we use the step, the checker has to establish this for the actual program we’re rewriting.

Most other steps are defined in cachet:steps in the same way. A few layout steps, such as split and order, are built into the compiler.

Blocking matrix multiplication

We now return to our astute reader’s observation: we keep fetching the same inputs for different outputs. Blocking lets us keep a block in cache while computing several outputs from it.

The inner kernel alone can take hundreds of lines: BLIS’s double-precision kernel for Haswell runs to about 720. Much of that code deals with registers and instructions. The code around the kernel divides the work into blocks, hands it out to threads, and organises memory access around the cache hierarchy of one particular machine.

The structure of matrix multiplication gives us several ways to divide up that work. We’ll start with the CPU: blocking, packing the inputs, and tuning the kernel. Then we’ll build GPU schedules from the same contraction.

Working through a sum in chunks

Each output has a sum to work through. We can cut it into consecutive pieces, provided the running total carries on from one piece to the next. Here are the first two steps of tower, a CPU schedule we’ll build up later:

First two stepsschedule tower of matmul =
  zero (i, j) c
  split k by 16 as gk ragged

1.5 GFLOP/s · 1,428.42 ms1.4% of OpenBLAS's speed

100% = OpenBLAS · CPU · one core · 108 GFLOP/s

CPU · 1024 × 1024 · f32

zero prepares the output cells as before. split divides the sum over k into chunks of at most 16 products. It names the chunk gk, and k now counts the position within it. The part that updates one output has this shape:

Loop body for one outputfor gk in blocks (0 .. K) 16 do
  for k in 0 .. gk.len do
    let acc = load c[i, j]
    store c[i, j] = fma (load a[i, gk[k]]) (load b[gk[k], j]) acc

gk.len is the chunk’s length, and gk[k] is the corresponding index in the original sum. At K = 40, the chunks cover k = 0 through 15, then 16 through 31, then 32 through 39. (ragged means the last chunk can be shorter.) The cell isn’t reset between chunks: it carries the same running total through the same products in the same order.

Giving each chunk a separate total and combining those totals afterwards would be a different transformation. It would let us compute the chunks independently, but it would regroup the additions. Over the reals that makes no difference (since addition is associative), but in floating point, each addition rounds, so the answer can change. A plain sum doesn’t allow that. We’ll use a contraction that gives permission to regroup when we get to attention.

Chunking lets us work through each output’s sum in pieces. To reuse inputs across neighbouring outputs, we need to work on several outputs together.

Reusing inputs across outputs

Two 8 × 8 matrices make the pattern visible. Grouping their entries into 4 × 4 blocks gives us a 2 × 2 matrix of matrices. The lower multiplication follows the same pattern as the ordinary 2 × 2 multiplication above it, except that each entry is now a block.

Reusing inputs across a 4 × 4 output block

Regrouping each matrix into four blocks.

Each of the four entries is now a 4 × 4 matrix.

Loading animation…

Scrub

Each block contains 4 × 4 entries