tests/testthat/test-ag-adam-fused.R

# Fused device Adam: every parameter's step as ONE graph of native
# GGML_OP_OPT_STEP_ADAMW nodes (.ag_adam_step_device_fused), instead of seven
# per-op launches per parameter. Measured on PPO before the change: the per-op
# step was 448 of the update's 456 launches.
#
# The fused kernel computes the same formula as the host step, but rounds
# differently -- lr * (m * (1/bc1)) against (lr / bc1) * m, about 1 ULP a step
# -- so the checks below use tolerances, not identity. Error grows with the step
# count, and a relative error is meaningless for a weight near zero, hence the
# mixed criterion: |d| <= max(abs, rel * |host|).
#
# Tied weights, unnamed lists and duplicate names are covered by
# test-ag-optimizer-params.R, which runs through the fused path by default.

skip_if_no_gpu <- function() {
  skip_if_not(ggml_vulkan_available() && ggml_vulkan_device_count() >= 1L,
              "no Vulkan device")
}

ns       <- asNamespace("ggmlR")
launches <- get("ag_launch_count", envir = ns)
h_to_mat <- get(".ag_as_matrix",   envir = ns)
# ag_tensor -> as.matrix() (downloads a device tensor); handle/matrix -> as is
as_mat <- function(x) if (inherits(x, "ag_tensor")) as.matrix(x) else h_to_mat(x)

# Run `code` with GGMLR_AG_ADAM_FUSED set to "1" or "0", restoring it after.
with_fused <- function(on, code) {
  old <- Sys.getenv("GGMLR_AG_ADAM_FUSED", unset = NA)
  Sys.setenv(GGMLR_AG_ADAM_FUSED = if (on) "1" else "0")
  on.exit(if (is.na(old)) Sys.unsetenv("GGMLR_AG_ADAM_FUSED")
          else Sys.setenv(GGMLR_AG_ADAM_FUSED = old), add = TRUE)
  force(code)
}

expect_adam_close <- function(dev, host, rel, abs, info = NULL) {
  d   <- abs(dev - host)
  lim <- pmax(abs, rel * abs(host))
  expect_true(all(d <= lim),
              info = paste(info, sprintf("max|d| = %.3g, worst d/lim = %.3g",
                                         max(d), max(d / lim))))
}

# Parameters of different shapes and gradient scales: a matrix with ordinary
# gradients, a column vector with tiny ones (v ~ 1e-12, close to eps) and a
# 1x1 with huge ones (g^2 ~ 1e6).
shapes <- list(a = c(3L, 4L), b = c(5L, 1L), c = c(1L, 1L))
scales <- c(a = 0.1, b = 1e-6, c = 1e3)
w_init <- lapply(shapes, function(s) matrix(seq(-0.4, 0.4, length.out = prod(s)), s[1], s[2]))
grad_at <- function(nm, k) {
  s <- shapes[[nm]]
  matrix(sin(seq_len(prod(s)) * 0.7 + k) * scales[[nm]], s[1], s[2])
}

# n Adam steps on `device`; snapshots of every weight after the steps in `at`.
# `lr_at(k)` gives the learning rate for step k (a scheduler stand-in).
run_adam <- function(device, n, at, lr_at = function(k) 0.01, ...) {
  ag_device(device)
  ps  <- lapply(w_init, ag_param)
  opt <- optimizer_adam(ps, lr = lr_at(1L), ...)
  snaps <- list()
  for (k in seq_len(n)) {
    opt$lr <- lr_at(k)
    for (nm in names(ps)) ps[[nm]]$grad <- grad_at(nm, k)
    opt$step()
    if (k %in% at) snaps[[as.character(k)]] <- lapply(ps, as_mat)
  }
  list(snaps = snaps, opt = opt, ps = ps)
}

# The kernel takes beta1/beta2 as floats and forms 1.0f - beta itself, so the
# weight of g^2 in v is 1 - fl(beta2), not 1 - beta2. The tolerances below are
# DERIVED from that, not fitted: e(beta2) = |(1 - fl(beta2)) - (1 - beta2)| /
# (1 - beta2) = 9.5e-7 (0.99), 1.29e-5 (0.999), 1.66e-4 (0.9999).
f32 <- function(x) readBin(writeBin(x, raw(), size = 4), "double", size = 4, n = length(x))

test_that("ggml_opt_step_adamw: one CPU step matches the float-beta model", {
  w0 <- c(0.5, -0.25, 1, 0); g <- c(0.1, -0.2, 0.3, 1e-6)
  m0 <- c(0.01, 0, -0.02, 0); v0 <- c(1e-4, 0, 2e-4, 0)
  lr <- 0.01; b1 <- 0.9; eps <- 1e-8; t <- 3L

  for (b2 in c(0.99, 0.999, 0.9999)) {
    info <- paste("beta2 =", b2)
    ctx <- ggml_init(1024 * 1024)
    mk <- function(x) { t <- ggml_new_tensor_1d(ctx, GGML_TYPE_F32, length(x)); ggml_set_f32(t, x); t }
    w <- mk(w0); gr <- mk(g); m <- mk(m0); v <- mk(v0)
    hp <- mk(c(lr, b1, b2, eps, 0, 1 / (1 - b1^t), 1 / (1 - b2^t)))
    # No ggml_set_param: the binding sets the flag only while building the node.
    node <- ggml_opt_step_adamw(ctx, w, gr, m, v, hp)
    ggml_graph_compute(ctx, ggml_build_forward_expand(ctx, node))
    mk_ <- ggml_get_f32(m); vk <- ggml_get_f32(v); wk <- ggml_get_f32(w)
    ggml_free(ctx)

    # Model of the kernel: every input rounded to float, the formula in double.
    F <- lapply(list(w0 = w0, g = g, m0 = m0, v0 = v0, lr = lr, b1 = b1, b2 = b2,
                     eps = eps, b1h = 1 / (1 - b1^t), b2h = 1 / (1 - b2^t)), f32)
    m_mod <- F$b1 * F$m0 + F$g * (1 - F$b1)
    v_mod <- F$b2 * F$v0 + F$g^2 * (1 - F$b2)
    w_mod <- F$w0 - F$lr * (m_mod * F$b1h) / (sqrt(v_mod * F$b2h) + F$eps)
    # Against the model only float rounding of a short chain remains.
    near <- function(a, b) all(abs(a - b) <= pmax(1e-6 * abs(b), 1e-30))
    expect_true(near(mk_, m_mod), info = paste(info, "m"))
    expect_true(near(vk, v_mod), info = paste(info, "v"))
    expect_true(near(wk, w_mod), info = paste(info, "w"))

    # Against the exact double formula, v may deviate by the derived amount:
    # e(beta2) on the g^2 term, the rounding of beta2 on the v0 term, plus the
    # same float rounding as above. Nothing larger.
    v_ex  <- b2 * v0 + (1 - b2) * g^2
    e_b2  <- abs((1 - f32(b2)) - (1 - b2)) / (1 - b2)
    bound <- e_b2 * (1 - b2) * g^2 + abs(f32(b2) - b2) * v0 + pmax(1e-6 * abs(v_ex), 1e-30)
    expect_true(all(abs(vk - v_ex) <= bound), info = paste(info, "v vs exact"))
  }

  # The model is not just a looser bound: for beta2 = 0.9999 the kernel's v is
  # closer to 1 - fl(beta2) than to 1 - beta2 (element 3, g = 0.3: the effect
  # is ~100 float ulps of v).
  b2 <- 0.9999
  ctx <- ggml_init(1024 * 1024)
  on.exit(ggml_free(ctx), add = TRUE)
  mk <- function(x) { t <- ggml_new_tensor_1d(ctx, GGML_TYPE_F32, length(x)); ggml_set_f32(t, x); t }
  w <- mk(w0); gr <- mk(g); m <- mk(m0); v <- mk(v0)
  hp <- mk(c(lr, b1, b2, eps, 0, 1 / (1 - b1^t), 1 / (1 - b2^t)))
  ggml_graph_compute(ctx, ggml_build_forward_expand(ctx, ggml_opt_step_adamw(ctx, w, gr, m, v, hp)))
  vk3 <- ggml_get_f32(v)[3]
  v_mod3 <- f32(b2) * f32(v0[3]) + f32(g[3])^2 * (1 - f32(b2))
  v_ex3  <- b2 * v0[3] + (1 - b2) * g[3]^2
  expect_lt(abs(vk3 - v_mod3), abs(vk3 - v_ex3))
})

test_that("ggml_opt_step_adamw refuses what the kernels would abort on", {
  ctx <- ggml_init(1024 * 1024)
  on.exit(ggml_free(ctx), add = TRUE)
  f32 <- function(n = 4L) ggml_new_tensor_1d(ctx, GGML_TYPE_F32, n)
  hp  <- f32(7L)

  w16 <- ggml_new_tensor_1d(ctx, GGML_TYPE_F16, 4L)
  expect_error(ggml_opt_step_adamw(ctx, w16, f32(), f32(), f32(), hp), "F32-only")
  expect_error(ggml_opt_step_adamw(ctx, f32(), f32(5L), f32(), f32(), hp), "same shape")
  expect_error(ggml_opt_step_adamw(ctx, f32(), f32(), f32(), f32(), f32(6L)), "7 elements")
})

test_that("fused step matches the host Adam at steps 1, 2, 10 and 100", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  at <- c(1L, 2L, 10L, 100L)
  host <- run_adam("cpu", 100L, at)$snaps
  dev  <- with_fused(TRUE, run_adam("gpu", 100L, at)$snaps)

  for (k in as.character(at)) for (nm in names(shapes)) {
    # Early steps must agree to rounding; later ones are allowed the
    # accumulated per-step ULP drift.
    tol <- if (as.integer(k) <= 2L) c(1e-5, 1e-6) else c(1e-4, 1e-5)
    expect_adam_close(dev[[k]][[nm]], host[[k]][[nm]], rel = tol[1], abs = tol[2],
                      info = paste0("step ", k, ", param ", nm))
  }
})

test_that("fused step is ONE launch per step, per-op path is many", {
  skip_if_no_gpu()
  ag_device("gpu"); on.exit(ag_device("cpu"), add = TRUE)

  count_step <- function(on) with_fused(on, {
    ps  <- lapply(w_init, ag_param)
    opt <- optimizer_adam(ps, lr = 0.01)
    for (nm in names(ps)) ps[[nm]]$grad <- grad_at(nm, 1L)
    launches(reset = TRUE)
    opt$step()
    cnt <- launches(reset = TRUE)
    # Counted is not computed: a node Vulkan skips (unbound view) still counts
    # as a launch. Every weight must have moved.
    moved <- vapply(names(ps), function(nm)
      !isTRUE(all.equal(as_mat(ps[[nm]]), w_init[[nm]])), logical(1))
    list(cnt = cnt, moved = moved)
  })

  fused <- count_step(TRUE)
  expect_true(all(fused$moved))
  fused <- fused$cnt
  expect_identical(unname(fused["adam step"]), 1)
  expect_true(is.na(fused["per-op"]) || fused["per-op"] == 0)

  # Guards the switch itself, and makes a silent return to the per-op path
  # visible: there it is several launches per parameter.
  perop <- count_step(FALSE)
  expect_true(all(perop$moved))
  perop <- perop$cnt
  expect_true(is.na(perop["adam step"]))
  expect_gte(unname(perop["per-op"]), 2 * length(shapes))
})

test_that("a learning-rate change mid-run reaches the fused step", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  lr_at <- function(k) if (k <= 3L) 0.05 else 0.005
  host <- run_adam("cpu", 6L, 6L, lr_at = lr_at)$snaps[["6"]]
  dev  <- with_fused(TRUE, run_adam("gpu", 6L, 6L, lr_at = lr_at)$snaps[["6"]])
  for (nm in names(shapes))
    expect_adam_close(dev[[nm]], host[[nm]], rel = 1e-4, abs = 1e-5, info = nm)

  # And the change is not a no-op: a constant rate lands elsewhere.
  const <- with_fused(TRUE, run_adam("gpu", 6L, 6L)$snaps[["6"]])
  expect_false(isTRUE(all.equal(const$a, dev$a)))
})

test_that("gradient accumulation feeds the fused step the averaged gradient", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  host <- run_adam("cpu", 8L, 8L, accumulate_steps = 2L)$snaps[["8"]]
  dev  <- with_fused(TRUE, run_adam("gpu", 8L, 8L, accumulate_steps = 2L)$snaps[["8"]])
  for (nm in names(shapes))
    expect_adam_close(dev[[nm]], host[[nm]], rel = 1e-4, abs = 1e-5, info = nm)
})

test_that("clip_grad_norm before the fused step matches the host", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  clipped_run <- function(device) {
    ag_device(device)
    ps  <- lapply(w_init, ag_param)
    opt <- optimizer_adam(ps, lr = 0.01)
    norms <- numeric(0)
    for (k in 1:5) {
      grads <- new.env()
      for (nm in names(ps)) assign(as.character(ps[[nm]]$id), grad_at(nm, k), envir = grads)
      norms <- c(norms, clip_grad_norm(ps, grads, max_norm = 1))  # c's 1e3 forces a clip
      opt$step(grads)
    }
    list(w = lapply(ps, as_mat), norms = norms)
  }
  host <- clipped_run("cpu")
  dev  <- with_fused(TRUE, clipped_run("gpu"))
  expect_true(all(host$norms > 1))
  for (nm in names(shapes))
    expect_adam_close(dev$w[[nm]], host$w[[nm]], rel = 1e-4, abs = 1e-5, info = nm)
})

# Training through backward() in graph mode, against an INDEPENDENT reference
# (host Adam on the CPU), not only against the other device path: two fast
# paths agreeing proves nothing if they share a defect, and disagreeing does
# not say which one is wrong.
#
# Two regimes, because the fused kernel has one known systematic difference:
# it forms 1 - beta2 in float, so the effective beta2 is 1 - fl(beta2)
# (0.99900001287 for 0.999; the (1 - beta2) term is off by 1.29e-5). On a ReLU
# network that small difference can flip a unit's derivative for a sample near
# zero and move single elements by ~1e-4 (measured on PPO tictactoe). So:
#   * betas EXACT in float (0.875, 1 - 2^-11): element-wise, tight -- catches
#     real defects of either path, independent of beta rounding;
#   * beta2 = 0.999 on a ReLU net: aggregates only (losses, parameter norms),
#     documenting the known deviation without flaking on element flips.
gm_train <- function(device, fused, relu, beta1, beta2) with_fused(fused, {
  ag_device(device)
  ag_local_mode(graph = TRUE, matmul_precision = "f32")
  set.seed(11L)
  W1 <- ag_param(matrix(rnorm(8 * 16) * 0.3, 16L, 8L))
  b1 <- ag_param(matrix(0, 16L, 1L))
  W2 <- ag_param(matrix(rnorm(16 * 4) * 0.3, 4L, 16L))
  b2 <- ag_param(matrix(0, 4L, 1L))
  x <- ag_tensor(matrix(rnorm(8 * 32), 8L, 32L))
  y <- ag_tensor(matrix(rnorm(4 * 32) * 0.1, 4L, 32L))
  ps  <- list(W1 = W1, b1 = b1, W2 = W2, b2 = b2)
  opt <- optimizer_adam(ps, lr = 0.02, beta1 = beta1, beta2 = beta2)
  losses <- numeric(0)
  for (k in 1:20) {
    loss <- NULL
    with_grad_tape({
      h <- ag_add(ag_matmul(W1, x), b1)
      if (relu) h <- ag_relu(h)
      loss <- ag_mse_loss(ag_add(ag_matmul(W2, h), b2), y)
    })
    opt$step(backward(loss))
    opt$zero_grad()
    losses <- c(losses, as.numeric(as_mat(loss)))
  }
  list(w = lapply(ps, as_mat), losses = losses)
})

test_that("graph mode, betas exact in float: both paths match the CPU element-wise", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  bt  <- c(0.875, 1 - 2^-11)
  ref <- gm_train("cpu", FALSE, relu = TRUE, bt[1], bt[2])
  expect_lt(ref$losses[20], ref$losses[1])
  for (on in c(TRUE, FALSE)) {
    got  <- gm_train("gpu", on, relu = TRUE, bt[1], bt[2])
    info <- if (on) "fused" else "per-op"
    expect_equal(got$losses, ref$losses, tolerance = 1e-5, info = info)
    for (nm in names(ref$w))
      expect_adam_close(got$w[[nm]], ref$w[[nm]], rel = 1e-5, abs = 1e-6,
                        info = paste(info, nm))
  }
})

test_that("graph mode, beta2 = 0.999 on a ReLU net: aggregates match the CPU", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  ref   <- gm_train("cpu", FALSE, relu = TRUE, 0.9, 0.999)
  norms <- function(w) vapply(w, function(m) sqrt(sum(m^2)), 0)
  for (on in c(TRUE, FALSE)) {
    got  <- gm_train("gpu", on, relu = TRUE, 0.9, 0.999)
    info <- if (on) "fused" else "per-op"
    expect_equal(got$losses, ref$losses, tolerance = 1e-3, info = info)
    expect_equal(norms(got$w), norms(ref$w), tolerance = 1e-3, info = info)
  }
})

test_that("a fused step leaves no PARAM flag: later graphs are bit-identical", {
  skip_if_no_gpu()
  ag_device("gpu"); on.exit(ag_device("cpu"), add = TRUE)
  set.seed(5L)
  x <- ag_tensor(matrix(rnorm(8 * 6), 8L, 6L))
  y <- ag_tensor(matrix(rnorm(3 * 6), 3L, 6L))
  fwd_bwd <- function(W) {
    loss <- NULL
    with_grad_tape({ loss <- ag_mse_loss(ag_matmul(W, x), y) })
    g <- backward(loss)
    list(loss = as_mat(loss), grad = as_mat(get(as.character(W$id), envir = g)))
  }

  W <- ag_param(matrix(rnorm(3 * 8), 3L, 8L))
  opt <- optimizer_adam(list(W = W), lr = 0.01)
  with_fused(TRUE, opt$step(list2env(setNames(list(fwd_bwd(W)$grad),
                                              as.character(W$id)))))
  after <- fwd_bwd(W)

  # A fresh parameter with the same values never went through the step, so
  # it never carried the flag. A lasting flag would make W a graph node
  # instead of a leaf; the results must not notice the difference.
  fresh <- fwd_bwd(ag_param(as_mat(W)))
  expect_identical(after$loss, fresh$loss)
  expect_identical(after$grad, fresh$grad)
})

test_that("checkpoints move between the per-op and fused paths", {
  skip_if_no_gpu()
  on.exit(ag_device("cpu"), add = TRUE)
  ag_device("gpu")

  # 3 steps on one path, state() -> load_state() into a fresh optimizer on the
  # other path, 3 more steps; against 6 uninterrupted steps on the second path.
  cross <- function(first, second) {
    w0  <- w_init$a
    W   <- ag_param(w0)
    opt <- optimizer_adam(list(W = W), lr = 0.02)
    with_fused(first, for (k in 1:3) { W$grad <- grad_at("a", k); opt$step() })
    st <- opt$state(); w3 <- as_mat(W)

    W2   <- ag_param(w3)
    opt2 <- optimizer_adam(list(W = W2), lr = 0.02)
    opt2$load_state(st)
    with_fused(second, for (k in 4:6) { W2$grad <- grad_at("a", k); opt2$step() })

    Wr   <- ag_param(w0)
    optr <- optimizer_adam(list(W = Wr), lr = 0.02)
    with_fused(second, for (k in 1:6) { Wr$grad <- grad_at("a", k); optr$step() })
    list(resumed = as_mat(W2), straight = as_mat(Wr), t = opt2$t)
  }
  for (dir in list(c(FALSE, TRUE), c(TRUE, FALSE))) {
    r <- cross(dir[1], dir[2])
    info <- paste(if (dir[1]) "fused" else "per-op", "->", if (dir[2]) "fused" else "per-op")
    expect_identical(r$t, 6L, info = info)
    expect_adam_close(r$resumed, r$straight, rel = 1e-5, abs = 1e-6, info = info)
  }
})

test_that("f16 weights take the fused step through an F32 master", {
  skip_if_no_gpu()
  ag_device("gpu")
  old <- ag_dtype("f16")
  on.exit({ ag_dtype(old); ag_device("cpu") }, add = TRUE)

  W <- ag_param(matrix(seq(-0.5, 0.5, length.out = 12L), 3L, 4L))
  opt <- optimizer_adam(list(W = W), lr = 0.05)
  w0 <- as_mat(W)
  cnt <- with_fused(TRUE, {
    launches(reset = TRUE)
    for (k in 1:3) { W$grad <- matrix(0.1, 3L, 4L); opt$step() }
    launches(reset = TRUE)
  })
  # The kernel is F32-only; the step runs on the master and an F32 gradient,
  # so an f16 weight no longer forces the per-op path.
  expect_identical(unname(cnt["adam step"]), 3)
  expect_true(all(is.finite(as_mat(W))))
  expect_false(isTRUE(all.equal(as_mat(W), w0)))
})

Try the ggmlR package in your browser

Any scripts or data that you put into this service are public.

ggmlR documentation built on Oct. 23, 2026, 1:07 a.m.