• 1. Online softmax
  • 2. Flash Attention 1
  • 3. Flash Attention 2
  • 4. Flash decoding
unitgridOpacitycanvasColororiginXoriginY

Kernel visualization: Flash Attention 1

  • Kernel implementation
  • 0. Outer iteration j = 0
    • 0.0 Inner iteration i = 0
    • 0.1 Inner iteration i = 1
  • 1. Outer iteration j=1
    • 1.0 Inner iteration i = 0
    • 1.1 Inner iteration i = 1
Prerequisites
  • Knowledge of CUDA essentials
  • How attention works
  • Online softmax

Flash Attention builds on the ideas behind online softmax. While online softmax requires two fetches from HBM, Flash Attention only requires one. Just like online softmax, the output matrix can be written as a recurrence formula:

oi′  ←  oi−1′⋅di−1′ emi−1−midi′⏟rescale old output  +  ∑j=1bexi[j]−midi′⏟softmax weight V[j+(i−1)b,  :]\textcolor{#b45309}{o'_i} \;\leftarrow\; \textcolor{#b45309}{o'_{i-1}} \cdot \underbrace{\frac{\textcolor{#047857}{d'_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}}{\textcolor{#047857}{d'_i}}}_{\text{rescale old output}} \;+\; \sum_{j=1}^{b} \underbrace{\frac{e^{x_i[j] - \textcolor{#1d4ed8}{m_i}}}{\textcolor{#047857}{d'_i}}}_{\text{softmax weight}} \, V\bigl[j + (i{-}1)b,\; :\bigr]oi′​←oi−1′​⋅rescale old outputdi′​di−1′​emi−1​−mi​​​​+j=1∑b​softmax weightdi′​exi​[j]−mi​​​​V[j+(i−1)b,:]
Derivation

Take one query row q=Q[k,:]q = Q[k,:]q=Q[k,:] and score it against every key, with the usual 1/d1/\sqrt{d}1/d​ scaling:

x[j]  =  q⋅K[j,:]d,j=1,…,Nx[j] \;=\; \frac{q \cdot K[j,:]}{\sqrt{d}}, \qquad j = 1, \dots, Nx[j]=d​q⋅K[j,:]​,j=1,…,N

The attention output for that row is a weighted average of the rows of VVV:

O[k,:]  =  ∑j=1Nex[j]−m∑t=1Nex[t]−m V[j,:],m=max⁡tx[t]O[k,:] \;=\; \sum_{j=1}^{N} \frac{e^{x[j] - m}}{\sum_{t=1}^{N} e^{x[t] - m}} \, V[j,:], \qquad m = \max_{t} x[t]O[k,:]=j=1∑N​∑t=1N​ex[t]−mex[j]−m​V[j,:],m=tmax​x[t]

Both mmm and the denominator need the whole row before a single output value can be written, and that is what we want to avoid. So take the keys one at a time and define three partial quantities after the first iii of them:

mi  =  max⁡j ≤ i  x[j]di′  =  ∑j=1iex[j]−mioi′  =  1di′∑j=1iex[j]−mi V[j,:]\begin{aligned} \textcolor{#1d4ed8}{m_i} \;&=\; \max_{j \,\le\, i} \; x[j] \\[10pt] \textcolor{#047857}{d'_i} \;&=\; \sum_{j=1}^{i} e^{x[j] - \textcolor{#1d4ed8}{m_i}} \\[10pt] \textcolor{#b45309}{o'_i} \;&=\; \frac{1}{\textcolor{#047857}{d'_i}} \sum_{j=1}^{i} e^{x[j] - \textcolor{#1d4ed8}{m_i}} \, V[j,:] \end{aligned}mi​di′​oi′​​=j≤imax​x[j]=j=1∑i​ex[j]−mi​=di′​1​j=1∑i​ex[j]−mi​V[j,:]​

The prime marks a partial value: each one is what you would get if the row simply ended at element iii. Note that oN′=O[k,:]\textcolor{#b45309}{o'_N} = O[k,:]oN′​=O[k,:] by construction. So if each of the three can be updated from its previous value plus the one new element, a single pass over KKK and VVV produces the right answer.

The running max. Nothing to derive:

mi  =  max⁡(mi−1,  x[i])\textcolor{#1d4ed8}{m_i} \;=\; \max\bigl(\textcolor{#1d4ed8}{m_{i-1}},\; x[i]\bigr)mi​=max(mi−1​,x[i])

The denominator. This is the online softmax step. Split off the new element, then rewrite the older terms against mi−1\textcolor{#1d4ed8}{m_{i-1}}mi−1​, which is the max they were actually summed against:

di′  =  ∑j=1i−1ex[j]−mi  +  ex[i]−mi=  emi−1−mi∑j=1i−1ex[j]−mi−1  +  ex[i]−mi=  di−1′ emi−1−mi⏟correct the old sum  +  ex[i]−mi⏟add the new element\begin{aligned} \textcolor{#047857}{d'_i} \;&=\; \sum_{j=1}^{i-1} e^{x[j] - \textcolor{#1d4ed8}{m_i}} \;+\; e^{x[i] - \textcolor{#1d4ed8}{m_i}} \\[10pt] &=\; e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}} \sum_{j=1}^{i-1} e^{x[j] - \textcolor{#1d4ed8}{m_{i-1}}} \;+\; e^{x[i] - \textcolor{#1d4ed8}{m_i}} \\[10pt] &=\; \underbrace{\textcolor{#047857}{d'_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}}_{\text{correct the old sum}} \;+\; \underbrace{e^{x[i] - \textcolor{#1d4ed8}{m_i}}}_{\text{add the new element}} \end{aligned}di′​​=j=1∑i−1​ex[j]−mi​+ex[i]−mi​=emi−1​−mi​j=1∑i−1​ex[j]−mi−1​+ex[i]−mi​=correct the old sumdi−1′​emi−1​−mi​​​+add the new elementex[i]−mi​​​​

The only real step is ex[j]−mi=ex[j]−mi−1 emi−1−mie^{x[j] - \textcolor{#1d4ed8}{m_i}} = e^{x[j] - \textcolor{#1d4ed8}{m_{i-1}}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}ex[j]−mi​=ex[j]−mi−1​emi−1​−mi​. The correction factor does not depend on jjj, so it comes out of the sum and what is left is exactly di−1′\textcolor{#047857}{d'_{i-1}}di−1′​. One multiply fixes every term already accumulated.

The output. oi′\textcolor{#b45309}{o'_i}oi′​ is a ratio, and the division is what makes it look awkward. So set the division aside and give the numerator its own name:

ui  =  ∑j=1iex[j]−mi V[j,:],so thatoi′=uidi′\textcolor{#b45309}{u_i} \;=\; \sum_{j=1}^{i} e^{x[j] - \textcolor{#1d4ed8}{m_i}} \, V[j,:], \qquad \text{so that} \quad \textcolor{#b45309}{o'_i} = \frac{\textcolor{#b45309}{u_i}}{\textcolor{#047857}{d'_i}}ui​=j=1∑i​ex[j]−mi​V[j,:],so thatoi′​=di′​ui​​

ui\textcolor{#b45309}{u_i}ui​ is the same sum as di′\textcolor{#047857}{d'_i}di′​ with V[j,:]V[j,:]V[j,:] attached to each term, so the identical split goes through:

ui  =  ∑j=1i−1ex[j]−mi V[j,:]  +  ex[i]−mi V[i,:]=  emi−1−mi∑j=1i−1ex[j]−mi−1 V[j,:]  +  ex[i]−mi V[i,:]=  ui−1 emi−1−mi  +  ex[i]−mi V[i,:]\begin{aligned} \textcolor{#b45309}{u_i} \;&=\; \sum_{j=1}^{i-1} e^{x[j] - \textcolor{#1d4ed8}{m_i}} \, V[j,:] \;+\; e^{x[i] - \textcolor{#1d4ed8}{m_i}} \, V[i,:] \\[10pt] &=\; e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}} \sum_{j=1}^{i-1} e^{x[j] - \textcolor{#1d4ed8}{m_{i-1}}} \, V[j,:] \;+\; e^{x[i] - \textcolor{#1d4ed8}{m_i}} \, V[i,:] \\[10pt] &=\; \textcolor{#b45309}{u_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}} \;+\; e^{x[i] - \textcolor{#1d4ed8}{m_i}} \, V[i,:] \end{aligned}ui​​=j=1∑i−1​ex[j]−mi​V[j,:]+ex[i]−mi​V[i,:]=emi−1​−mi​j=1∑i−1​ex[j]−mi−1​V[j,:]+ex[i]−mi​V[i,:]=ui−1​emi−1​−mi​+ex[i]−mi​V[i,:]​

Now put the division back. Substitute ui=di′ oi′\textcolor{#b45309}{u_i} = \textcolor{#047857}{d'_i} \, \textcolor{#b45309}{o'_i}ui​=di′​oi′​ on both sides:

di′ oi′  =  di−1′ oi−1′ emi−1−mi  +  ex[i]−mi V[i,:]\textcolor{#047857}{d'_i} \, \textcolor{#b45309}{o'_i} \;=\; \textcolor{#047857}{d'_{i-1}} \, \textcolor{#b45309}{o'_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}} \;+\; e^{x[i] - \textcolor{#1d4ed8}{m_i}} \, V[i,:]di′​oi′​=di−1′​oi−1′​emi−1​−mi​+ex[i]−mi​V[i,:]

and divide by di′\textcolor{#047857}{d'_i}di′​:

oi′  =  oi−1′⋅di−1′ emi−1−midi′⏟rescale old output  +  ex[i]−midi′⏟softmax weight V[i,:]\textcolor{#b45309}{o'_i} \;=\; \textcolor{#b45309}{o'_{i-1}} \cdot \underbrace{\frac{\textcolor{#047857}{d'_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}}{\textcolor{#047857}{d'_i}}}_{\text{rescale old output}} \;+\; \underbrace{\frac{e^{x[i] - \textcolor{#1d4ed8}{m_i}}}{\textcolor{#047857}{d'_i}}}_{\text{softmax weight}} \, V[i,:]oi′​=oi−1′​⋅rescale old outputdi′​di−1′​emi−1​−mi​​​​+softmax weightdi′​ex[i]−mi​​​​V[i,:]

That is the recurrence. The rescale factor reads as one job: di−1′\textcolor{#047857}{d'_{i-1}}di−1′​ undoes the normalizer oi−1′\textcolor{#b45309}{o'_{i-1}}oi−1′​ was divided by, emi−1−mie^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}emi−1​−mi​ shifts it to the new max, and dividing by di′\textcolor{#047857}{d'_i}di′​ normalizes against the new denominator. Starting values are m0=−∞\textcolor{#1d4ed8}{m_0} = -\inftym0​=−∞, d0′=0\textcolor{#047857}{d'_0} = 0d0′​=0, o0′=0\textcolor{#b45309}{o'_0} = \mathbf{0}o0′​=0.

The tiled version

Hardware reads memory in tiles, not single elements. Nothing about the recurrence changes: with tiles of width bbb, the max is taken over the tile and the two single new terms become sums over the tile.

for i←1,…,N/b  doxi  ←  1d Q[k,:] K⊤ ⁣[:, (i−1)b:ib]mi(local)⏟max of this tile  =  max⁡j=1b(xi[j])mi  ←  max⁡(mi−1,  mi(local))di′  ←  di−1′ emi−1−mi⏟rescale old denom  +  ∑j=1bexi[j]−mi⏟new tile’s contributionoi′  ←  oi−1′⋅di−1′ emi−1−midi′⏟rescale old output  +  ∑j=1bexi[j]−midi′⏟softmax weight V[j+(i−1)b,  :]endO[k,:]  ←  oN/b′\begin{aligned} & \textbf{for } i \leftarrow 1, \dots, N/b \; \textbf{do} \\[16pt] & \qquad x_i \;\leftarrow\; \frac{1}{\sqrt{d}} \, Q[k,:] \, K^\top\!\bigl[:,\, (i{-}1)b : ib\bigr] \\[18pt] & \qquad \underbrace{m_i^{(\mathrm{local})}}_{\text{max of this tile}} \;=\; \max_{j=1}^{b} \bigl(x_i[j]\bigr) \\[22pt] & \qquad \textcolor{#1d4ed8}{m_i} \;\leftarrow\; \max\bigl(\textcolor{#1d4ed8}{m_{i-1}},\; m_i^{(\mathrm{local})}\bigr) \\[22pt] & \qquad \textcolor{#047857}{d'_i} \;\leftarrow\; \underbrace{\textcolor{#047857}{d'_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}}_{\text{rescale old denom}} \;+\; \underbrace{\sum_{j=1}^{b} e^{x_i[j] - \textcolor{#1d4ed8}{m_i}}}_{\text{new tile's contribution}} \\[28pt] & \qquad \textcolor{#b45309}{o'_i} \;\leftarrow\; \textcolor{#b45309}{o'_{i-1}} \cdot \underbrace{\frac{\textcolor{#047857}{d'_{i-1}} \, e^{\textcolor{#1d4ed8}{m_{i-1}} - \textcolor{#1d4ed8}{m_i}}}{\textcolor{#047857}{d'_i}}}_{\text{rescale old output}} \;+\; \sum_{j=1}^{b} \underbrace{\frac{e^{x_i[j] - \textcolor{#1d4ed8}{m_i}}}{\textcolor{#047857}{d'_i}}}_{\text{softmax weight}} \, V\bigl[j + (i{-}1)b,\; :\bigr] \\[16pt] & \textbf{end} \\[16pt] & O[k,:] \;\leftarrow\; \textcolor{#b45309}{o'_{N/b}} \end{aligned}​for i←1,…,N/bdoxi​←d​1​Q[k,:]K⊤[:,(i−1)b:ib]max of this tilemi(local)​​​=j=1maxb​(xi​[j])mi​←max(mi−1​,mi(local)​)di′​←rescale old denomdi−1′​emi−1​−mi​​​+new tile’s contributionj=1∑b​exi​[j]−mi​​​oi′​←oi−1′​⋅rescale old outputdi′​di−1′​emi−1​−mi​​​​+j=1∑b​softmax weightdi′​exi​[j]−mi​​​​V[j+(i−1)b,:]endO[k,:]←oN/b′​​

Here iii now counts tiles rather than elements, and xi[j]x_i[j]xi​[j] is the jjj-th score inside tile iii. Only one tile of K,VK, VK,V and the three carries live in fast memory at any time.

Every query row runs this loop independently, and rows share the tile of KKK and VVV that is already in fast memory. So the kernel below works on a block of BrBrBr rows at once, and the carries become vectors: mi and li hold one running max and one denominator per row, and Oi holds one partial output row each.


Kernel implementation

The visualization shows how data moves from HBM to a single block in SRAM, how the intermediate values are calculated, and finally how the output is moved back to HBM. Each block operates identically on different tiles.

# Drawing helpers: memory slots in HBM and SRAM, and the actions the kernel below takes on them.
# Each action is recorded into the current step; `show_steps` draws the steps.
import json
import math

INK, GRAY = '#27272a', '#3f3f46'  # text; outlines

SLOTS = []  # every slot, in drawing order: HBM first, so that data moving into SRAM passes over it

class Slot:
    """A place in HBM or SRAM that holds one tile, value or register, drawn as a w by h box centered at (x, y).
    `data`: what it holds at the start. `kind`: the palette its updates use. An SRAM slot is also drawn empty
    (`outline`), with `caption`, by default its name, under it."""
    def __init__(self, name, memory, x, y, w, h, data=None, font=14, kind=None, outline=None, caption=None):
        self.name, self.memory, self.x, self.y, self.w, self.h = name, memory, x, y, w, h
        self.initial, self.font, self.kind = data, font, kind
        self.outline = memory == 'sram' if outline is None else outline
        self.caption = name if caption is None else caption
        SLOTS.append(self)

def data(label='', fill='none', line=GRAY, level=0):
    """What a slot holds, as drawn: a label, a fill and an outline color. `level` counts its updates."""
    return dict(label=label, fill=fill, line=line, level=level)

def as_list(x):
    return list(x) if isinstance(x, (list, tuple)) else [x]

# ---- The actions ----

def new_figure(block=None, caption='Setup ready'):
    """Start recording. `block`: (square, slots) of the CUDA block SRAM shows from the start, or None."""
    global FRAMES, state, pending, block_now, transfers
    state = {s: s.initial for s in SLOTS}
    block_now, pending, transfers = block, {}, {'fetches': 0, 'stores': 0}
    FRAMES = [dict(label='start', caption=caption, events={}, state=dict(state), block=block)]

def content(slot):
    """What `slot` holds now, this step's actions included."""
    return pending[slot][2] if slot in pending else state[slot]

def move_data(src, dst):
    """Copy the data in `src` to `dst`, slots or lists of slots, e.g. a tile from HBM to SRAM. Data without a label
    takes the label `dst` had. One call is one transfer."""
    for a, b in zip(as_list(src), as_list(dst)):
        moved = dict(content(a))
        moved['label'] = moved['label'] or (content(b) or data())['label']
        pending[b] = ('move', a, moved, 0, 1)
    a, b = as_list(src)[0], as_list(dst)[0]
    if a.memory != b.memory:
        transfers['fetches' if a.memory == 'hbm' else 'stores'] += 1

def change(slots, one_by_one=False, **fields):
    """Change the data in `slots` in place: `fields` replace its label, fill, line or level.
    With `one_by_one`, the slots change one after another, e.g. the cells of a matrix."""
    slots = as_list(slots)
    for n, s in enumerate(slots):
        new = {**(content(s) or data()), **fields}
        pending[s] = ('change', None, new, n if one_by_one else 0, len(slots) if one_by_one else 1)

def run_block(square, slots):
    """From this step, SRAM shows the shared memory of the CUDA block at `square` of the SRAM grid: `slots`, empty."""
    global block_now
    block_now = (square, tuple(slots))
    for s in slots:
        state[s] = s.initial

def step(label, caption=None):
    """End a step. `label` names it in the step list; `caption`, by default `label`, is shown on the canvas."""
    for s, event in pending.items():
        state[s] = event[2]
    FRAMES.append(dict(label=label, caption=caption or label, events=dict(pending), state=dict(state), block=block_now))
    pending.clear()

# ---- Drawing: the whole figure is one Diagram node, and the step list changes it ----

def shown(s, frame):
    """Whether `frame` shows slot s: HBM always, SRAM while its CUDA block is the one shown."""
    return s.memory == 'hbm' or (frame['block'] is not None and s in frame['block'][1])

def item(x=0, y=0, w=0, h=0, fill='none', stroke='none', label='', font=12, wrap=0, line=False):
    """One shape of the diagram: a box with (x, y) its bottom-left corner, or a line from (x, y) to (x + w, y + h).
    A box of size 0 draws only its label."""
    return dict(x=round(x, 4), y=round(y, 4), w=round(w, 4), h=round(h, 4), fill=fill, stroke=stroke,
                label=label, font=font, wrap=wrap, line=line, color=INK)

ROLES = ('outline', 'under', 'top', 'ring', 'caption')  # each slot's items, in drawing order

def slot_items(s, k, phase):
    """Slot s's items at step k, by role: its empty outline and its caption, what it held before the step ('under'),
    what it holds ('top'), and a ring while it changes in place. `phase` is 'start', 'move' or 'end': how the step
    begins, where its moves go, and how it ends."""
    frame, items = FRAMES[k], dict.fromkeys(ROLES, item())
    if not shown(s, frame):
        return items
    bottom = s.y - s.h / 2

    def holding(d, cx=s.x, cy=s.y, h=s.h):
        return item(cx - s.w / 2, cy - s.h / 2, s.w, h, d['fill'], d['line'], d['label'], s.font) if d else item()

    if s.outline:
        items['outline'] = item(s.x - s.w / 2, bottom, s.w, s.h, stroke=GRAY)
        items['caption'] = item(s.x, bottom - 0.3, label=s.caption, font=min(s.font, 12))
    top, event = frame['state'][s], frame['events'].get(s)
    under = FRAMES[k - 1]['state'][s] if event else None
    if event is None or phase == 'end':
        items['top'] = holding(top)
    elif event[0] == 'move':  # the copy flies in over what the slot held
        start = (event[1].x, event[1].y) if phase == 'start' else (s.x, s.y)
        items['under'], items['top'] = holding(under), holding(top, *start)
    else:  # a change in place: the new data fills the slot from the bottom, under its new label
        old = holding(under) if under else item(s.x - s.w / 2, bottom, s.w, s.h)
        items['under'] = dict(old, label=top['label'], font=s.font)
        items['top'] = dict(holding(top, h=0 if phase == 'start' else s.h), label='')
    if event is not None and event[0] == 'change':
        items['ring'] = item(s.x - s.w / 2 - 0.06, bottom - 0.06, s.w + 0.12, s.h + 0.12, stroke=INK)
    return items

def zoom_ends(block, side):
    """The zoom line on `side` (0 left, 1 right) of `block`: from a bottom corner of its square in the SRAM grid
    to the top corner of its slots on that side."""
    (bx, by), slots = block
    edge = min(s.x - s.w / 2 for s in slots) if side == 0 else max(s.x + s.w / 2 for s in slots)
    return (bx + 0.3 * side, by), (edge, max(s.y + s.h / 2 for s in slots))

def zoom_items(k, phase):
    """The two zoom lines at step k. They grow when SRAM is first shown, and slide when the block shown changes."""
    now = FRAMES[k]['block']
    before = FRAMES[k - 1]['block'] if k else now
    lines = []
    for side in (0, 1):
        if now is None or now[0] is None:
            lines.append(item(line=True))
            continue
        start, end = zoom_ends(now, side)
        if phase == 'start' and (before is None or before[0] is None):
            end = start
        elif phase == 'start' and before != now:
            start, end = zoom_ends(before, side)
        lines.append(item(*start, end[0] - start[0], end[1] - start[1], stroke=GRAY, line=True))
    return lines

def rect(left, bottom, w, h, **colors):
    """A rectangle. Every argument but the colors can be a node."""
    return pie.rectangle(pie.point(left, bottom, hidden=True), w, h, **colors)

def show_steps(name, background, caption_at=(0, -5.6), caption_width=8, unit=50):
    """Draw the recorded steps as one Diagram node, with a step list that plays them."""
    pie.clear()
    pie.show_canvas('right')
    pie.scalar(0, out='gridOpacity')
    pie.scalar(unit, out='unit')
    background()
    squares = list(dict.fromkeys(f['block'][0] for f in FRAMES if f['block'] and f['block'][0]))

    def snapshot(k, phase):
        """Every item at step k: the filled square of the block shown, the zoom lines, each role of every slot, the caption."""
        block = FRAMES[k]['block']
        fills = [item(*q, 0.3, 0.3, GRAY, GRAY) if block and block[0] == q else item(*q) for q in squares]
        slots = [slot_items(s, k, phase) for s in SLOTS]
        caption = item(*caption_at, label=FRAMES[k]['caption'], wrap=caption_width)
        return fills + zoom_items(k, phase) + [items[role] for role in ROLES for items in slots] + [caption]

    def diff(a, b):
        """[index, changed fields] of each item that differs from `a` to `b`."""
        return [[i, {key: v for key, v in new.items() if old[key] != v}] for i, (old, new) in enumerate(zip(a, b)) if old != new]

    first_top = len(squares) + 2 + ROLES.index('top') * len(SLOTS)  # the index of the first slot's 'top' item
    steps, last = [dict(label=FRAMES[0]['label'])], snapshot(0, 'end')
    for k in range(1, len(FRAMES)):
        start, move, end = (snapshot(k, phase) for phase in ('start', 'move', 'end'))
        events = FRAMES[k]['events']
        stagger = [[first_top + i, events[s][3], events[s][4]] for i, s in enumerate(SLOTS) if s in events and events[s][4] > 1]
        steps.append(dict(label=FRAMES[k]['label'], start=diff(last, start), move=diff(start, move), end=diff(move, end), stagger=stagger))
        last = end
    figure = pie.diagram(json.dumps(snapshot(0, 'end')), out=f'{name}_figure')
    pie.ui.step_list(figure, steps=steps)

# ---- This page: where the slots are, and the kernel's actions on them ----

TILE = {'Q': ('#99f6e4', '#115e59'), 'K': ('#fecaca', '#b91c1c'), 'V': ('#f4f4f5', GRAY)}  # (fill, outline)
# The carries, in the hues of the equations: (outline, fill once one K, V tile is folded in, fill once both are)
CARRY = {'O': ('#b45309', '#fde68a', '#f59e0b'), 'l': ('#047857', '#a7f3d0', '#34d399'), 'm': ('#1d4ed8', '#bfdbfe', '#60a5fa')}
S_LINE, S_FILLS = '#7c3aed', ('#ddd6fe', '#a78bfa')  # Sij: each new Sij takes the other fill

def hbm_tiles(name, x, w, font=14):
    """Tiles 0 and 1 of matrix `name` in HBM."""
    fill, line = TILE[name] if name in TILE else ('none', CARRY[name][0])
    return [Slot(f'{name}{n}', 'hbm', x, y, w, 1.2, data(f'{name}{n}', fill, line), font) for n, y in enumerate((3.625, 2.375))]

Q, K, V = hbm_tiles('Q', -3.15, 1.6), hbm_tiles('K', -1.35, 1.6), hbm_tiles('V', 0.45, 1.6)
O, l, m = hbm_tiles('O', 2.25, 1.6), hbm_tiles('l', 3.375, 0.25, 10), hbm_tiles('m', 3.825, 0.25, 10)

# The shared memory of one CUDA block
Qi = Slot('Qi', 'sram', -3.7, -3.0, 1.6, 1.2)
Kj = Slot('Kj', 'sram', -1.95, -3.0, 1.6, 1.2)
Sij = Slot('Sij', 'sram', -0.4, -3.0, 1.2, 1.2)
Sij.cells = [Slot(f'Sij[{r}][{c}]', 'sram', -0.85 + 0.3 * c, -2.55 - 0.3 * r, 0.3, 0.3, outline=False)
             for r in range(4) for c in range(4)]
Vj = Slot('Vj', 'sram', 1.15, -3.0, 1.6, 1.2)
mi = Slot('mi', 'sram', 2.225, -3.0, 0.25, 1.2, font=10, kind='m')
li = Slot('li', 'sram', 2.625, -3.0, 0.25, 1.2, font=10, kind='l')
Oi = Slot('Oi', 'sram', 3.7, -3.0, 1.6, 1.2, kind='O')
SRAM = [s for s in SLOTS if s.memory == 'sram']

def compute(S):
    """Compute matrix S, e.g. Sij = Qi @ Kj.T: its cells fill one after another."""
    level = (content(S.cells[0]) or data())['level'] + 1
    change(S.cells, one_by_one=True, fill=S_FILLS[level % 2], line=S_LINE, level=level)

def update(*carries):
    """The online softmax update of mi, li and Oi with one more K, V tile: each turns a shade darker."""
    for s in carries:
        level = (content(s) or data())['level'] + 1
        line, *shades = CARRY[s.kind]
        change(s, fill=shades[min(level, len(shades)) - 1], line=line, level=level)

def background():
    """What doesn't change: the names of HBM, SRAM and the matrices, and the SRAM grid of CUDA blocks."""
    pie.text('HBM', 0, 5, 14, stroke=INK)
    for label, x in (('Q', -3.15), ('K', -1.35), ('V', 0.45), ('O', 2.25), ('L', 3.375), ('m', 3.825)):
        pie.text(label, x, 1.5, stroke=INK)
    pie.text('SRAM', 0, 0.6, 14, stroke=INK)
    for y in (0.05, -0.35):
        for x in (-0.55, -0.15, 0.25):
            rect(x, y, 0.3, 0.3, stroke=GRAY)
# The kernel: K and V on the outer loop, Q on the inner loop
new_figure(block=((-0.15, -0.35), SRAM))  # SRAM shows one CUDA block: the filled square of the SRAM grid
Tr = Tc = 2  # blocks of Q rows; tiles of K and V

for j in range(Tc):
    move_data([K[j], V[j]], [Kj, Vj])                      # Kj, Vj = K[j], V[j]
    step(f'{j}. load K{j}, V{j}', f'[Outer loop: {j}] load K{j}, V{j}')
    for i in range(Tr):
        loop = f'[Outer loop: {j}, inner loop: {i}]'
        move_data([Q[i], m[i], l[i], O[i]], [Qi, mi, li, Oi])  # Qi, mi, li, Oi = Q[i], m[i], L[i], O[i]
        step(f'{j}.{i} load Q{i}, m{i}, l{i}, O{i}', f'{loop} load Q{i}, m{i}, l{i}, O{i}')
        compute(Sij)                                       # Sij = Qi @ Kj.T
        step(f'{j}.{i} fill Sij', f'{loop} fill Sij')
        update(mi, li, Oi)                                 # mi, li, Oi = rescale, then add this tile
        step(f'{j}.{i} update mi, li and Oi', f'{loop} update mi, li and Oi')
        move_data([mi, li, Oi], [m[i], l[i], O[i]])        # m[i], L[i], O[i] = mi, li, Oi
        step(f'{j}.{i} write m{i}, l{i}, O{i}', f'{loop} write m{i}, l{i}, O{i}')

show_steps('fa1', background)

Run the two editors in order: the first defines the drawing and the actions on memory, the second is the kernel. Then pick a step. Drag the step list by its ⋮⋮ grip onto the canvas to keep it in view while you read.

Initial setup
template<int Br, int Bc, int D>
__global__ void fa1(const float* Q, const float* K, const float* V,
                    float* O, float* l, float* m, int N, float scale) {
    int bh = blockIdx.x, tid = threadIdx.x;
    Q += bh*N*D; K += bh*N*D; V += bh*N*D; O += bh*N*D;
    l += bh*N;   m += bh*N;

    __shared__ float Qi[Br][D], Oi[Br][D], Kj[Bc][D], Vj[Bc][D];
    __shared__ float Sij[Br][Bc], mi[Br], li[Br];

    int Tr = (N+Br-1)/Br, Tc = (N+Bc-1)/Bc;

0. Outer iteration j = 0

One K/V tile is loaded into SRAM and stays there while every Q block is swept against it.

  • Kj, Vj = K[j], V[j]
Cuda implementation
// j = 0
for (int x = tid; x < Bc*D; x += blockDim.x) {
    Kj[x/D][x%D] = K[(0*Bc + x/D)*D + x%D];
    Vj[x/D][x%D] = V[(0*Bc + x/D)*D + x%D];
}
__syncthreads();

0.0 Inner iteration i = 0

It carries empty mi, li, Oi from HBM.

  • Qi, mi, li, Oi = Q[i], m[i], L[i], O[i]
Cuda implementation
// j = 0, i = 0
for (int x = tid; x < Br*D; x += blockDim.x) {
    Qi[x/D][x%D] = Q[(0*Br + x/D)*D + x%D];
    Oi[x/D][x%D] = O[(0*Br + x/D)*D + x%D];
}
if (tid < Br) { mi[tid] = m[0*Br+tid]; li[tid] = l[0*Br+tid]; }
__syncthreads();
  • Sij = Qi @ Kj.T
Cuda implementation
// j = 0, i = 0
for (int x = tid; x < Br*Bc; x += blockDim.x) {
    int r = x/Bc, c = x%Bc;
    float s = 0.f;
    for (int k = 0; k < D; k++) s += Qi[r][k]*Kj[c][k];
    Sij[r][c] = s*scale;
}
__syncthreads();
  • mi = max(mi, Sij.max(-1))
  • li = rescale(li) + exp(Sij - mi).sum(-1)
  • Oi = rescale(Oi) + softmax(Sij) @ Vj
Cuda implementation
// j = 0, i = 0
if (tid < Br) {
    float m_ij = -INFINITY;
    for (int c = 0; c < Bc; c++) m_ij = fmaxf(m_ij, Sij[tid][c]);
    float m_new = fmaxf(mi[tid], m_ij);
    float alpha = expf(mi[tid] - m_new);
    float lsum = 0.f;
    for (int c = 0; c < Bc; c++) {
        Sij[tid][c] = expf(Sij[tid][c] - m_new);
        lsum += Sij[tid][c];
    }
    float l_new = alpha*li[tid] + lsum;
    for (int d = 0; d < D; d++) {
        float pv = 0.f;
        for (int c = 0; c < Bc; c++) pv += Sij[tid][c]*Vj[c][d];
        Oi[tid][d] = (alpha*li[tid]*Oi[tid][d] + pv) / l_new;
    }
    mi[tid] = m_new; li[tid] = l_new;
}
__syncthreads();
  • m[i], L[i], O[i] = mi, li, Oi
Cuda implementation
// j = 0, i = 0
for (int x = tid; x < Br*D; x += blockDim.x)
    O[(0*Br + x/D)*D + x%D] = Oi[x/D][x%D];
if (tid < Br) { m[0*Br+tid] = mi[tid]; l[0*Br+tid] = li[tid]; }
__syncthreads();

0.1 Inner iteration i = 1

It carries empty mi, li, Oi from HBM

  • Qi, mi, li, Oi = Q[i], m[i], L[i], O[i]
Cuda implementation
// j = 0, i = 1
for (int x = tid; x < Br*D; x += blockDim.x) {
    Qi[x/D][x%D] = Q[(1*Br + x/D)*D + x%D];
    Oi[x/D][x%D] = O[(1*Br + x/D)*D + x%D];
}
if (tid < Br) { mi[tid] = m[1*Br+tid]; li[tid] = l[1*Br+tid]; }
__syncthreads();
  • Sij = Qi @ Kj.T
Cuda implementation
// j = 0, i = 1
for (int x = tid; x < Br*Bc; x += blockDim.x) {
    int r = x/Bc, c = x%Bc;
    float s = 0.f;
    for (int k = 0; k < D; k++) s += Qi[r][k]*Kj[c][k];
    Sij[r][c] = s*scale;
}
__syncthreads();
  • mi = max(mi, Sij.max(-1))
  • li = rescale(li) + exp(Sij - mi).sum(-1)
  • Oi = rescale(Oi) + softmax(Sij) @ Vj
Cuda implementation
// j = 0, i = 1
if (tid < Br) {
    float m_ij = -INFINITY;
    for (int c = 0; c < Bc; c++) m_ij = fmaxf(m_ij, Sij[tid][c]);
    float m_new = fmaxf(mi[tid], m_ij);
    float alpha = expf(mi[tid] - m_new);
    float lsum = 0.f;
    for (int c = 0; c < Bc; c++) {
        Sij[tid][c] = expf(Sij[tid][c] - m_new);
        lsum += Sij[tid][c];
    }
    float l_new = alpha*li[tid] + lsum;
    for (int d = 0; d < D; d++) {
        float pv = 0.f;
        for (int c = 0; c < Bc; c++) pv += Sij[tid][c]*Vj[c][d];
        Oi[tid][d] = (alpha*li[tid]*Oi[tid][d] + pv) / l_new;
    }
    mi[tid] = m_new; li[tid] = l_new;
}
__syncthreads();
  • m[i], L[i], O[i] = mi, li, Oi
Cuda implementation
// j = 0, i = 1
for (int x = tid; x < Br*D; x += blockDim.x)
    O[(1*Br + x/D)*D + x%D] = Oi[x/D][x%D];
if (tid < Br) { m[1*Br+tid] = mi[tid]; l[1*Br+tid] = li[tid]; }
__syncthreads();

1. Outer iteration j=1

K0, V0 are dropped and the next K/V tile takes their place, and the same Q blocks come round again, now carrying the partial results left behind by j = 0.

  • Kj, Vj = K[j], V[j]
Cuda implementation
// j = 1
for (int x = tid; x < Bc*D; x += blockDim.x) {
    Kj[x/D][x%D] = K[(1*Bc + x/D)*D + x%D];
    Vj[x/D][x%D] = V[(1*Bc + x/D)*D + x%D];
}
__syncthreads();

1.0 Inner iteration i = 0

It carries partial (not empty) mi, li, Oi from HBM

  • Qi, mi, li, Oi = Q[i], m[i], L[i], O[i]
Cuda implementation
// j = 1, i = 0
for (int x = tid; x < Br*D; x += blockDim.x) {
    Qi[x/D][x%D] = Q[(0*Br + x/D)*D + x%D];
    Oi[x/D][x%D] = O[(0*Br + x/D)*D + x%D];
}
if (tid < Br) { mi[tid] = m[0*Br+tid]; li[tid] = l[0*Br+tid]; }
__syncthreads();
  • Sij = Qi @ Kj.T
Cuda implementation
// j = 1, i = 0
for (int x = tid; x < Br*Bc; x += blockDim.x) {
    int r = x/Bc, c = x%Bc;
    float s = 0.f;
    for (int k = 0; k < D; k++) s += Qi[r][k]*Kj[c][k];
    Sij[r][c] = s*scale;
}
__syncthreads();
  • mi = max(mi, Sij.max(-1))
  • li = rescale(li) + exp(Sij - mi).sum(-1)
  • Oi = rescale(Oi) + softmax(Sij) @ Vj
Cuda implementation
// j = 1, i = 0
if (tid < Br) {
    float m_ij = -INFINITY;
    for (int c = 0; c < Bc; c++) m_ij = fmaxf(m_ij, Sij[tid][c]);
    float m_new = fmaxf(mi[tid], m_ij);
    float alpha = expf(mi[tid] - m_new);
    float lsum = 0.f;
    for (int c = 0; c < Bc; c++) {
        Sij[tid][c] = expf(Sij[tid][c] - m_new);
        lsum += Sij[tid][c];
    }
    float l_new = alpha*li[tid] + lsum;
    for (int d = 0; d < D; d++) {
        float pv = 0.f;
        for (int c = 0; c < Bc; c++) pv += Sij[tid][c]*Vj[c][d];
        Oi[tid][d] = (alpha*li[tid]*Oi[tid][d] + pv) / l_new;
    }
    mi[tid] = m_new; li[tid] = l_new;
}
__syncthreads();
  • m[i], L[i], O[i] = mi, li, Oi
Cuda implementation
// j = 1, i = 0
for (int x = tid; x < Br*D; x += blockDim.x)
    O[(0*Br + x/D)*D + x%D] = Oi[x/D][x%D];
if (tid < Br) { m[0*Br+tid] = mi[tid]; l[0*Br+tid] = li[tid]; }
__syncthreads();

1.1 Inner iteration i = 1

It carries partial mi, li, Oi from HBM

  • Qi, mi, li, Oi = Q[i], m[i], L[i], O[i]
Cuda implementation
// j = 1, i = 1
for (int x = tid; x < Br*D; x += blockDim.x) {
    Qi[x/D][x%D] = Q[(1*Br + x/D)*D + x%D];
    Oi[x/D][x%D] = O[(1*Br + x/D)*D + x%D];
}
if (tid < Br) { mi[tid] = m[1*Br+tid]; li[tid] = l[1*Br+tid]; }
__syncthreads();
  • Sij = Qi @ Kj.T
Cuda implementation
// j = 1, i = 1
for (int x = tid; x < Br*Bc; x += blockDim.x) {
    int r = x/Bc, c = x%Bc;
    float s = 0.f;
    for (int k = 0; k < D; k++) s += Qi[r][k]*Kj[c][k];
    Sij[r][c] = s*scale;
}
__syncthreads();
  • mi = max(mi, Sij.max(-1))
  • li = rescale(li) + exp(Sij - mi).sum(-1)
  • Oi = rescale(Oi) + softmax(Sij) @ Vj
Cuda implementation
// j = 1, i = 1
if (tid < Br) {
    float m_ij = -INFINITY;
    for (int c = 0; c < Bc; c++) m_ij = fmaxf(m_ij, Sij[tid][c]);
    float m_new = fmaxf(mi[tid], m_ij);
    float alpha = expf(mi[tid] - m_new);
    float lsum = 0.f;
    for (int c = 0; c < Bc; c++) {
        Sij[tid][c] = expf(Sij[tid][c] - m_new);
        lsum += Sij[tid][c];
    }
    float l_new = alpha*li[tid] + lsum;
    for (int d = 0; d < D; d++) {
        float pv = 0.f;
        for (int c = 0; c < Bc; c++) pv += Sij[tid][c]*Vj[c][d];
        Oi[tid][d] = (alpha*li[tid]*Oi[tid][d] + pv) / l_new;
    }
    mi[tid] = m_new; li[tid] = l_new;
}
__syncthreads();
  • m[i], L[i], O[i] = mi, li, Oi
Cuda implementation
// j = 1, i = 1
for (int x = tid; x < Br*D; x += blockDim.x)
    O[(1*Br + x/D)*D + x%D] = Oi[x/D][x%D];
if (tid < Br) { m[1*Br+tid] = mi[tid]; l[1*Br+tid] = li[tid]; }
__syncthreads();

Counting trips across HBM:

  • KKK and VVV are read once
  • QQQ, OOO, mmm and lll are read twice, once per tile of KKK and VVV, with OOO, mmm and lll written back twice as well.
    • Why are mmm and lll moved? Every step but the last leaves OOO unfinished, and mmm and lll are the running max and running sum the next step needs to carry on. Only after the final tile of KKK and VVV is OOO the real answer.

Flash Attention 2 improves on it by putting QQQ on the outer loop, so each block of OOO is written just once.