home

GPU Batching

Creator: Janosh Riebesell

Small atomistic jobs can leave a GPU underused. Batching runs several together; replacing completed jobs keeps useful work in the batch.


GPU Batching

Download

PNG PDF SVG

Code

gpu-batching.typ (482 lines)

#import "@preview/cetz:0.5.2": canvas, draw
#import draw: bezier, circle, content, line, rect
#let label-size = 12pt
#let paragraph-size = 14pt
#let heading-size = 16pt

#let card_body(title, body, caption) = block(
  width: 100%,
  inset: 12pt,
  radius: 8pt,
  fill: rgb("#cdd3da"),
  breakable: false,
)[
  #text(size: heading-size, weight: "bold", title)
  #v(8pt)
  // Measure unconstrained artwork before scaling, including content wider than its card.
  #layout(size => {
    let artwork = text(size: label-size, body)
    std.scale(size.width / measure(artwork).width * 100%, reflow: true, artwork)
  })
  #v(7pt)
  #text(size: paragraph-size, caption)
]

#let card(title, body, caption) = grid(
  columns: (100%,),
  card_body(title, body, caption),
)

#let takeaway(body) = block(
  width: 100%,
  inset: 12pt,
  radius: 6pt,
  fill: rgb("#c6d8d2"),
  breakable: false,
  text(size: paragraph-size, body),
)

#set page(width: 780pt, height: auto, margin: 22pt, fill: none)
#set text(font: "Avenir Next", size: paragraph-size, fill: rgb("#19324f"))
#set par(leading: 0.55em)

// Size of compact annotations shared by both panels.
#let annotation-size = label-size

// === 1  Compare scheduling strategies ===
#let figure-0 = [

  // Ionic steps each structure needs before it converges. Every other number in this
  // figure -- batch spans, idle slots, utilization, total runtime -- is derived from this
  // one array, so the three strategies are compared on identical work.
  #let steps-needed = (2, 6, 4, 3, 6, 5, 2, 4, 3, 2)
  #let slots = 5
  #let window = 16 // steps of timeline drawn; unbatched runs off the end

  // Vega category10: ten hues that stay distinct, unlike shades of one hue.
  #let struct-colors = (
    rgb("#4C78A8"),
    rgb("#F58518"),
    rgb("#54A24B"),
    rgb("#E45756"),
    rgb("#B279A2"),
    rgb("#72B7B2"),
    rgb("#B8A02E"),
    rgb("#9D755D"),
    rgb("#D6698E"),
    rgb("#7F7F7F"),
  )

  // Each schedule is a list of steps, each step listing which structure occupies each
  // slot, or -1 for an idle slot.

  // One structure at a time: four slots always idle.
  #let unbatched-schedule = {
    let timeline = ()
    for (idx, needed) in steps-needed.enumerate() {
      for _ in range(needed) { timeline.push((idx,) + (-1,) * (slots - 1)) }
    }
    timeline
  }

  // Fixed batches: every slot is held until the batch's slowest member converges, so a
  // finished structure leaves a hole rather than making room for the next one.
  #let binning-schedule = {
    let timeline = ()
    for first in range(0, steps-needed.len(), step: slots) {
      let batch = steps-needed.slice(first, first + slots)
      for step in range(calc.max(..batch)) {
        timeline.push(range(slots).map(s => if batch.at(s) > step { first + s } else { -1 }))
      }
    }
    timeline
  }

  // In-flight: a converged structure is replaced immediately, so slots stay busy until
  // the queue runs dry.
  #let inflight-schedule = {
    let occupant = range(slots)
    let left = steps-needed.slice(0, slots)
    let next = slots
    let timeline = ()
    while occupant.any(idx => idx != -1) {
      timeline.push(occupant)
      for slot in range(slots) {
        if occupant.at(slot) == -1 { continue }
        left.at(slot) -= 1
        if left.at(slot) > 0 { continue }
        if next == steps-needed.len() {
          occupant.at(slot) = -1
          continue
        }
        occupant.at(slot) = next
        left.at(slot) = steps-needed.at(next)
        next += 1
      }
    }
    timeline
  }

  // Share of slot-steps that did useful work over the whole run.
  #let utilization(timeline) = {
    let busy = timeline.map(step => step.filter(s => s != -1).len()).sum()
    calc.round(100 * busy / (timeline.len() * slots))
  }

  #canvas(length: .95cm, {
    let dark-gray = rgb("#5D6B7A")
    let section-bg = rgb("#d1d6db")
    let idle-stroke = rgb("#C7D0D9")
    let good = rgb("#2E7D32")
    let bad = rgb("#C62828")

    let plot-width = 24
    let x0 = 5.0 // left edge of step 0
    let cell = 1.0 // one step per tick, so the grid never drifts off the axis
    let box-width = 0.88
    let row-height = 0.55
    let row-gap = 0.12
    let radius = 0.05

    // step i spans [x0 + i, x0 + i + 1]; its tick and label sit under the center
    let step-center(i) = x0 + (i + 0.5) * cell

    let slot-y(base, slot) = base + slot * (row-height + row-gap)

    let panels = (
      (11.2, "Unbatched\nSimulations", unbatched-schedule),
      (6.0, "Binning\nAutoBatcher", binning-schedule),
      (0.8, "InFlight\nAutoBatcher", inflight-schedule),
    )

    content(
      (plot-width / 2, 14.2),
      text(size: label-size, fill: dark-gray)[
        Ten structures needing #steps-needed.map(str).join(", ") ionic steps, on #slots GPU slots
      ],
    )

    for (base-y, name, schedule) in panels {
      let rows-bottom = base-y - 0.6
      let axis-y = rows-bottom - 0.55
      rect(
        (0.5, axis-y - 0.75),
        (plot-width - 0.5, slot-y(rows-bottom, slots - 1) + 0.75),
        fill: section-bg,
        stroke: none,
        radius: radius * 3,
      )
      content((2.3, base-y + 0.9), text(weight: "bold", size: 12pt)[#name])

      // occupancy meter, sized to its own text so the label cannot overflow it
      let busy = utilization(schedule)
      let meter = 3.6
      rect(
        (1.0, base-y - 0.65),
        (1.0 + meter, base-y - 0.05),
        fill: rgb("#c6cdd6"),
        stroke: 0.5pt,
        radius: 0.08,
        name: "meter-" + name,
      )
      rect(
        (1.0, base-y - 0.65),
        (1.0 + meter * busy / 100, base-y - 0.05),
        fill: if busy < 40 { bad.lighten(60%) } else if busy < 75 {
          rgb("#F9A825").lighten(55%)
        } else { good.lighten(60%) },
        stroke: none,
        radius: (west: 0.08),
      )
      content("meter-" + name, text(size: annotation-size, weight: "bold")[#busy% slots busy])
      content(
        (1.0, base-y - 1.3),
        text(size: annotation-size, fill: dark-gray)[#schedule.len() steps\ to finish all 10],
        anchor: "west",
      )

      // slot grid
      for (step, occupants) in schedule.slice(0, calc.min(schedule.len(), window)).enumerate() {
        for slot in range(slots) {
          let who = occupants.at(slot)
          let (x, y) = (step-center(step), slot-y(rows-bottom, slot))
          let color = if who == -1 { none } else { struct-colors.at(who) }
          rect(
            (x - box-width / 2, y),
            (x + box-width / 2, y + row-height),
            fill: if color == none { none } else { color.lighten(60%) },
            stroke: if color == none {
              (dash: "dotted", paint: idle-stroke, thickness: 0.6pt)
            } else { color },
            radius: radius,
          )
          if who != -1 {
            content((x, y + row-height / 2), text(size: annotation-size)[S#(who + 1)])
          }
        }
      }

      line(
        (x0 - 0.2, axis-y),
        (x0 + window * cell + 0.4, axis-y),
        stroke: 0.8pt,
        mark: (end: "stealth", fill: black, scale: 0.5),
      )
      for step in range(window) {
        line(
          (step-center(step), axis-y - 0.05),
          (step-center(step), axis-y + 0.05),
          stroke: 0.8pt,
        )
        content(
          (step-center(step), axis-y - 0.12),
          text(size: annotation-size)[t=#(step + 1)],
          anchor: "north",
        )
      }
    }

    // each panel's dead space on the right carries its own verdict
    let (unbatched-y, binning-y, inflight-y) = panels.map(panel => panel.first())
    let mid-rows(base-y) = slot-y(base-y - 0.6, 2) + row-height / 2
    content(
      (step-center(16.9), slot-y(unbatched-y - 0.6, 0) + row-height / 2),
      text(size: annotation-size, fill: bad)[
        …#(unbatched-schedule.len() - window) more steps
      ],
      anchor: "west",
    )
    content(
      (step-center(13.8), mid-rows(binning-y)),
      text(size: annotation-size, fill: bad, style: "italic")[
        #align(
          center,
        )[holes open up as a batch\ drains, and stay open until\ its slowest member converges]
      ],
      anchor: "center",
    )
    content(
      (step-center(13.8), mid-rows(inflight-y)),
      text(size: annotation-size, fill: good, style: "italic")[
        #align(center)[all ten converged by t=#inflight-schedule.len()]
      ],
      anchor: "center",
    )
  })
]

// === 2  Follow a replacement ===
#let figure-1 = [

  #canvas(length: 1.12cm, {
    let arrow-style = (mark: (end: "stealth", fill: black, scale: 0.5))
    let plot = (width: 18, height: 8)
    let structure = (width: 2.4, row-height: 1.4)
    let step = (width: 3.5, padding: 0.3, spacing: 1.0, y-offset: 0.4)
    let title-height = plot.height + 1.5
    let step-x = idx => step.width / 2 + 0.5 + idx * (step.width + step.spacing)

    let blue = rgb("#8bc6f6")
    let green = rgb("#48BB78")
    let orange = rgb("#ED8936")
    let purple-1 = rgb("#cdbfea")
    let red = rgb("#F56565")
    let pink = rgb("#ED64A6")
    let yellow = rgb("#ECC94B")
    let teal = rgb("#81E6D9")
    let purple-2 = rgb("#9F7AEA")

    // solid arrow for a structure continuing into the next step
    let continuing(from, to, color) = bezier(
      from,
      to,
      (rel: (0.5, 0), to: from),
      (rel: (-0.5, 0), to: to),
      stroke: color + 0.8pt,
      mark: (end: "stealth", fill: color, scale: 0.6),
    )

    // dotted arrow for allocation from / convergence to a pool
    let pool-arrow(from, to, ctrl-from, ctrl-to, color, mark-pos) = bezier(
      from,
      to,
      (rel: ctrl-from, to: from),
      (rel: ctrl-to, to: to),
      stroke: (dash: "dotted", paint: color),
      mark: (pos: mark-pos, end: "stealth", fill: color, scale: 0.6, shorten-to: none),
    )

    for (x, label, fill, name, padding) in (
      (
        plot.width / 3 - 1,
        [Initial Structure Pool],
        rgb(230, 255, 230),
        "initial-pool",
        (7pt, 9pt, 8pt),
      ),
      (
        2 * plot.width / 3 + 1,
        [Converged Structure Pool],
        rgb(255, 230, 230),
        "relaxed-pool",
        (7pt, 9pt, 4pt),
      ),
    ) {
      content(
        (x, title-height - 1.2),
        text(size: 14pt, label),
        frame: "rect",
        padding: padding,
        stroke: none,
        fill: fill,
        name: name,
      )
    }

    line((0, 0), (plot.width, 0), ..arrow-style, name: "x-axis")
    line((0, 0), (0, plot.height), ..arrow-style, name: "y-axis")
    content(
      (rel: (-.4, 0), to: "y-axis.mid"),
      align(horizon, rotate(-90deg)[Memory Usage]),
      name: "y-label",
    )

    line((0, 7), (plot.width, 7), stroke: (dash: "dotted", thickness: 1pt), name: "memory-limit")
    content(
      (rel: (0.2, -0.1), to: "memory-limit.start"),
      text(size: label-size)[Maximum memory threshold\ (based on GPU capacity)],
      anchor: "north-west",
    )

    // memory-usage region, atom total and step label per batch
    for (idx, (rows, total)) in ((3, "150"), (4, "160"), (3, "145"), (4, "170")).enumerate() {
      let name = "batch" + str(idx + 1)
      rect(
        (step-x(idx) - step.width / 2, step.y-offset),
        (step-x(idx) + step.width / 2, rows * structure.row-height + step.padding + step.y-offset),
        fill: rgb(240, 240, 240),
        stroke: none,
        radius: 0.5,
        name: name,
      )
      content(
        (rel: (0, 0.1), to: name + ".north"),
        text(size: annotation-size)[#total atoms total],
        anchor: "south",
      )
      content((rel: (0, -0.7), to: name + ".south"), [*Step #(idx + 1)*])
    }

    let base-y = step.y-offset + 0.8

    // (step, row, color, label, atoms, name suffix, converged); the suffix distinguishes
    // repeat appearances of a structure that has not relaxed yet
    for (step-idx, row, color, label, atoms, suffix, converged) in (
      (0, 0, blue, "1", 50, "", true),
      (0, 1, green, "2", 45, "", false),
      (0, 2, orange, "3", 55, "", false),
      (1, 0, purple-1, "4", 40, "", true),
      (1, 1, green, "2", 45, "-2", true),
      (1, 2, orange, "3", 55, "-2", false),
      (1, 3, red, "5", 20, "", true),
      (2, 0, orange, "3", 50, "-3", true),
      (2, 1, pink, "6", 60, "", false),
      (2, 2, yellow, "7", 35, "", false),
      (3, 0, pink, "6", 60, "-2", false),
      (3, 1, yellow, "7", 35, "-2", true),
      (3, 2, teal, "8", 45, "", false),
      (3, 3, purple-2, "9", 30, "", false),
    ) {
      let (x, y) = (step-x(step-idx), base-y + row * structure.row-height)
      let name = "struct-" + label + suffix
      rect(
        (x - structure.width / 2, y - 0.4),
        (x + structure.width / 2, y + 0.4),
        fill: color,
        stroke: 0.5pt,
        radius: 0.2, // max 0.2 due to CeTZ 0.5.0 border anchor bug with rounded rects
        name: name,
      )
      content(name, [Structure #label])
      content(
        (rel: (0, -0.5), to: name),
        text(size: annotation-size)[#atoms atoms],
        anchor: "north",
      )
      if converged {
        content(
          (rel: (0.1, 0), to: name + ".east"),
          text(size: 12pt, fill: rgb("#38A169"))[✓],
          anchor: "west",
        )
      }
    }

    // continuation dots after step 4
    for row in range(4) {
      for dot in range(3) {
        circle(
          (step-x(3) + 0.65 * step.width + dot * 0.25, base-y + row * structure.row-height),
          radius: 0.1,
          stroke: 0.2pt,
          fill: rgb("#CBD5E0"),
        )
      }
    }

    // structures that survive a step reappear in the next one
    for (from, to, color) in (
      ("struct-2", "struct-2-2", green),
      ("struct-3", "struct-3-2", orange),
      ("struct-3-2", "struct-3-3", orange),
      ("struct-6", "struct-6-2", pink),
      ("struct-7", "struct-7-2", yellow),
    ) {
      continuing(from + ".east", to + ".west", color)
    }

    // relaxed structures peel off to the converged pool
    for (anchor, ctrl-to, color, mark-pos) in (
      ("struct-1.north-east", (-0.25, -0.5), blue, 30%),
      ("struct-2-2.north-east", (0, -0.5), green, 50%),
      ("struct-4.north-east", (0.25, -0.5), purple-1, 50%),
      ("struct-5.north-east", (0.5, -0.5), red, 50%),
      ("struct-3-3.north-east", (0.25, -5), orange, 50%),
      ("struct-7-2.north-east", (0.75, -0.5), yellow, 25%),
    ) {
      pool-arrow(anchor, "relaxed-pool.south", (0, 0.5), ctrl-to, color, mark-pos)
    }

    // freed capacity is refilled from the initial pool
    for (anchor, ctrl-from, color, mark-pos) in (
      ("struct-4.north-west", (-0.5, -5), purple-2, 50%),
      ("struct-5.north-east", (0.15, -0.5), red, 50%),
      ("struct-6.north-west", (-0.15, -0.5), pink, 20%),
      ("struct-7.north-west", (-0.3, -0.5), yellow, 20%),
      ("struct-8.north-west", (-0.4, -0.5), teal, 50%),
      ("struct-9.north-west", (-0.25, -0.5), purple-2, 50%),
    ) {
      pool-arrow("initial-pool.south", anchor, ctrl-from, (0, 0.5), color, mark-pos)
    }
  })
]

Small atomistic jobs can leave a GPU underused. Batching runs several together; replacing completed jobs keeps useful work in the batch.
#v(14pt)
#stack(
  dir: ttb,
  spacing: 12pt,
  card(
    [1  Compare scheduling strategies],
    figure-0,
    [*Unbatched* leaves four of five slots idle. *Binning* holds each slot until the batch's slowest member converges. *In-flight* immediately reuses freed slots: a color change within a row marks a new structure taking over.],
  ),
  card(
    [2  Follow a replacement],
    figure-1,
    [Each structure has its own convergence state. After a structure finishes, move it to the completed pool and fill the available capacity from the waiting pool.],
  ),
)
#v(12pt)
#takeaway[*Batch by resource use, not just job count.* Atom counts, neighbor counts, memory, and model cost constrain admission. Utilization percentages in the schematic are illustrative, not measured benchmarks.]