tests/testthat/test-ag-backward-graph.R

# Tests for the graph backward path (R/ag_backward_graph.R).
#
# The path computes backward() as ONE ggml graph instead of one R closure per
# tape node. It is an optimisation, so the thing worth testing is not that it
# runs but that it computes THE SAME GRADIENTS as the closures it replaces --
# a fast wrong backward would train a model to nothing while looking healthy.
#
# So every numeric test here is a comparison against the closure path on the
# same tape, not against hand-written expected values. The closure path is the
# reference implementation; if the two ever disagree, this file says so.
#
# The second thing tested is the fallback. Stage 1 emits matmul, add and
# mse_loss only, and a tape containing anything else must fall back WHOLESALE
# rather than mixing paths. That is a correctness property, not a performance
# one: a half-graph backward would silently drop gradients for the ops it
# skipped.

# The graph path's controls are internal (no @export), so reach them the way a
# probe does. `:::` keeps the test working under plain testthat::test_dir() as
# well as under test_check(), which runs inside the package namespace -- the
# unqualified name only resolves in the latter, and that difference is exactly
# what made these tests pass locally and fail on the installed package.
ag_backward_graph <- ggmlR:::ag_backward_graph
ag_backward_path  <- ggmlR:::ag_backward_path
`%||%`            <- ggmlR:::`%||%`

skip_if_no_gpu <- function() {
  if (!ggml_vulkan_available() || ggml_vulkan_device_count() < 1L)
    skip("no Vulkan device")
}

# Run the same tape twice -- once through each backward path -- and return both
# gradient sets. Seeding before each build keeps the two tapes identical.
both_paths <- function(build) {
  old <- ag_backward_graph(FALSE)
  on.exit(ag_backward_graph(old %||% FALSE), add = TRUE)

  ag_backward_graph(FALSE)
  ref <- build()
  ref_grads <- backward(ref$loss)
  ref_path  <- ag_backward_path()

  ag_backward_graph(TRUE)
  got <- build()
  got_grads <- backward(got$loss)
  got_path  <- ag_backward_path()

  list(ref = ref_grads, ref_params = ref$params, ref_path = ref_path,
       got = got_grads, got_params = got$params, got_path = got_path)
}

# Gradients come back as device handles when residency is on, so they are read
# through the accessor rather than as fields. A handle has no arithmetic on
# purpose (rule 3 of the data contract), which is why `a - b` on one fails
# loudly instead of quietly computing the wrong thing -- helpful in the engine,
# but here it just means the test has to materialise first.
.bwd_as_matrix <- get(".ag_as_matrix", envir = asNamespace("ggmlR"))
.bwd_dim       <- get(".ag_dim",       envir = asNamespace("ggmlR"))

# Max absolute difference between the gradients of matching parameters.
grad_maxdiff <- function(r) {
  stopifnot(length(r$ref_params) == length(r$got_params))
  max(vapply(seq_along(r$ref_params), function(i) {
    a <- r$ref_params[[i]]$grad
    b <- r$got_params[[i]]$grad
    if (is.null(a) && is.null(b)) return(0)
    if (is.null(a) || is.null(b)) return(Inf)   # one path produced no gradient
    max(abs(.bwd_as_matrix(a) - .bwd_as_matrix(b)))
  }, numeric(1)))
}

test_that("graph backward matches the closure backward on a matmul chain", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  build <- function() {
    set.seed(11L)
    w1 <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    w2 <- ag_param(matrix(runif(6,  -1, 1), 3, 2))
    x  <- ag_tensor(matrix(runif(4 * 5, -1, 1), 5, 4))
    y  <- ag_tensor(matrix(runif(5 * 2, -1, 1), 5, 2))
    loss <- NULL
    with_grad_tape({
      h    <- ag_matmul(x, w1)
      o    <- ag_matmul(h, w2)
      loss <- ag_mse_loss(o, y)
    })
    list(loss = loss, params = list(w1, w2))
  }

  r <- both_paths(build)

  expect_identical(r$ref_path, "closures")
  expect_identical(r$got_path, "graph")
  # f16 accumulation on Vulkan: the tolerance is the device's, not the rule's.
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("graph backward matches the closure backward with a bias add", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # No broadcast: the bias is full-shape, which is the add case stage 1 emits.
  build <- function() {
    set.seed(12L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    b <- ag_param(matrix(runif(15, -1, 1), 5, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      o    <- ag_add(ag_matmul(x, w), b)
      loss <- ag_mse_loss(o, y)
    })
    list(loss = loss, params = list(w, b))
  }

  r <- both_paths(build)

  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})


test_that("graph backward handles a column-broadcast bias", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # b is [m,1] broadcast across columns -- the shape ag_linear uses, and the one
  # that needs the transposed reduction (sum_rows cannot reduce ne[1] directly).
  build <- function() {
    set.seed(22L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    b <- ag_param(matrix(runif(5,  -1, 1), 5, 1))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_add(ag_matmul(x, w), b), y)
    })
    list(loss = loss, params = list(w, b))
  }

  r <- both_paths(build)

  expect_identical(r$got_path, "graph")
  # The reduction must also produce the right SHAPE: a [1,m] gradient for a
  # [m,1] parameter would broadcast silently in the optimizer.
  expect_identical(.bwd_dim(r$got_params[[2]]$grad), c(5L, 1L))
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("graph backward handles a row-broadcast bias", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  build <- function() {
    set.seed(23L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    b <- ag_param(matrix(runif(3,  -1, 1), 1, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_add(ag_matmul(x, w), b), y)
    })
    list(loss = loss, params = list(w, b))
  }

  r <- both_paths(build)

  expect_identical(r$got_path, "graph")
  expect_identical(.bwd_dim(r$got_params[[2]]$grad), c(1L, 3L))
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("graph backward matches closures on a real ag_linear stack", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # The case stage 1 exists for: two ag_linear layers with no activation, so
  # every tape node is matmul/add/mse_loss and the whole pass qualifies. The
  # bias is [out,1], i.e. the column broadcast.
  build <- function() {
    set.seed(31L)
    l1 <- ag_linear(6L, 4L)
    l2 <- ag_linear(4L, 2L)
    x  <- ag_tensor(matrix(runif(6 * 8, -1, 1), 6, 8))
    y  <- ag_tensor(matrix(runif(2 * 8, -1, 1), 2, 8))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(l2$forward(l1$forward(x)), y)
    })
    list(loss = loss, params = list(l1$W, l1$b, l2$W, l2$b))
  }

  r <- both_paths(build)

  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
  expect_identical(.bwd_dim(r$got_params[[2]]$grad), c(4L, 1L))
  expect_identical(.bwd_dim(r$got_params[[4]]$grad), c(2L, 1L))
})


test_that("graph backward matches closures for relu, sigmoid and tanh", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # All three reduce to dx = g * mult with a multiplier the forward pass already
  # computed, so they share one emission rule. Testing them together is what
  # catches a rule wired to the wrong multiplier -- each has a different one.
  for (act in list(list(f = ag_relu,    nm = "relu"),
                   list(f = ag_sigmoid, nm = "sigmoid"),
                   list(f = ag_tanh,    nm = "tanh"))) {
    build <- function() {
      set.seed(41L)
      w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
      x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
      y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
      loss <- NULL
      with_grad_tape({
        loss <- ag_mse_loss(act$f(ag_matmul(x, w)), y)
      })
      list(loss = loss, params = list(w))
    }

    r <- both_paths(build)

    expect_identical(r$got_path, "graph", info = act$nm)
    expect_lt(grad_maxdiff(r), 1e-3)
  }
})

test_that("graph backward matches closures on an activated ag_linear stack", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # The realistic case: two ag_linear layers WITH activations, so the tape mixes
  # matmul, broadcast add, two different activations and the loss -- and still
  # has to run entirely as one graph.
  build <- function() {
    set.seed(31L)
    l1 <- ag_linear(6L, 4L, activation = "relu")
    l2 <- ag_linear(4L, 2L, activation = "tanh")
    x  <- ag_tensor(matrix(runif(6 * 8, -1, 1), 6, 8))
    y  <- ag_tensor(matrix(runif(2 * 8, -1, 1), 2, 8))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(l2$forward(l1$forward(x)), y)
    })
    list(loss = loss, params = list(l1$W, l1$b, l2$W, l2$b))
  }

  r <- both_paths(build)

  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("an op outside the covered set still forces a fallback", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # ag_pow records no op description, so the tape must decline AS A WHOLE even
  # though every other node on it is covered. That is the guarantee that keeps a
  # partly covered tape from silently dropping the gradients it cannot emit.
  #
  # This test has had to be rewritten twice, as relu and then softmax gained
  # emitters -- so pick the op deliberately: it must be one that is genuinely
  # outside .AG_BWD_GRAPH_OPS, and it should be re-pointed rather than deleted
  # when ag_pow is eventually covered too (ag_sub was, in the PPO wave).
  build <- function() {
    set.seed(51L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    b <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_add(ag_pow(ag_matmul(x, w), 2), b), y)
    })
    list(loss = loss, params = list(w))
  }

  r <- both_paths(build)

  expect_match(r$got_path, "^closures \\(")
  expect_lt(grad_maxdiff(r), 1e-6)   # same code ran twice: no device tolerance
})


test_that("graph backward matches closures for the three losses", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # All three share one emission rule (loss_const): a matrix the forward pass
  # already built, times a scalar. Only gmat and gscale differ, so testing all
  # three together is what catches a rule wired to the wrong pair.
  build_sce <- function() {
    set.seed(61L)
    w <- ag_param(matrix(runif(12, -1, 1), 3, 4))
    x <- ag_tensor(matrix(runif(24, -1, 1), 4, 6))
    y <- c(0L, 2L, 1L, 0L, 1L, 2L)
    loss <- NULL
    with_grad_tape({
      loss <- ag_softmax_cross_entropy_loss(ag_matmul(w, x), y)
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build_sce)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)

  build_ce <- function() {
    set.seed(62L)
    w  <- ag_param(matrix(runif(12, -1, 1), 3, 4))
    x  <- ag_tensor(matrix(runif(24, -1, 1), 4, 6))
    tg <- matrix(0, 3, 6)
    for (i in 1:6) tg[((i - 1L) %% 3L) + 1L, i] <- 1
    loss <- NULL
    with_grad_tape({
      loss <- ag_cross_entropy_loss(ag_sigmoid(ag_matmul(w, x)), tg)
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build_ce)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("graph backward matches closures for transpose, softmax and scale", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # transpose: dx = t(g), emitted as cont(transpose(g)).
  build_t <- function() {
    set.seed(71L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 3, 5))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_transpose(ag_matmul(x, w)), y)
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build_t)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)

  # softmax: the column coupling makes this four nodes, not an elementwise one.
  build_s <- function() {
    set.seed(72L)
    w <- ag_param(matrix(runif(12, -1, 1), 3, 4))
    x <- ag_tensor(matrix(runif(24, -1, 1), 4, 6))
    y <- ag_tensor(matrix(runif(18, -1, 1), 3, 6))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_softmax(ag_matmul(w, x)), y)
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build_s)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)

  # scale: a scalar multiplier, so nothing is uploaded for the rule at all.
  build_sc <- function() {
    set.seed(75L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_scale(ag_matmul(x, w), 0.3), y)
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build_sc)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("multi-head attention runs entirely as one graph", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # ag_multihead_attention is composed from the primitives above -- its head
  # slicing and concatenation go through selector matrices, i.e. ag_matmul, and
  # the causal mask is an ag_add of a constant. So it needs no emitter of its
  # own, and this test is what proves that claim rather than assuming it: a
  # single uncovered node anywhere in those ~31 tape entries forces a fallback.
  for (causal in c(FALSE, TRUE)) {
    build <- function() {
      set.seed(73L)
      at <- ag_multihead_attention(8L, 2L)
      x  <- ag_tensor(matrix(runif(48, -1, 1), 8, 6))
      y  <- ag_tensor(matrix(runif(48, -1, 1), 8, 6))
      loss <- NULL
      with_grad_tape({
        loss <- ag_mse_loss(at$forward(x, causal_mask = causal), y)
      })
      list(loss = loss, params = at$parameters())
    }

    r <- both_paths(build)

    expect_identical(r$got_path, "graph", info = paste("causal =", causal))
    expect_lt(grad_maxdiff(r), 1e-3)
  }
})


test_that("graph backward matches closures for elementwise mul and dropout", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # Both operands tracked: each gradient needs the OTHER operand's forward
  # value, so a rule that used the wrong snapshot would still produce
  # correctly-shaped nonsense. Two distinct parameters catch that.
  build_mul <- function() {
    set.seed(81L)
    w1 <- ag_param(matrix(runif(15, -1, 1), 5, 3))
    w2 <- ag_param(matrix(runif(15, -1, 1), 5, 3))
    y  <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(ag_mul(w1, w2), y)
    })
    list(loss = loss, params = list(w1, w2))
  }
  r <- both_paths(build_mul)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)

  # ag_dropout is one ag_mul against a constant mask, so covering mul covers it.
  # The mask is random, so both paths must build the tape under the same seed --
  # both_paths reseeds inside build().
  build_dp <- function() {
    set.seed(82L)
    l  <- ag_linear(4L, 3L)
    dp <- ag_dropout(0.3)
    x  <- ag_tensor(matrix(runif(20, -1, 1), 4, 5))
    y  <- ag_tensor(matrix(runif(15, -1, 1), 3, 5))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(dp$forward(l$forward(x)), y)
    })
    list(loss = loss, params = list(l$W, l$b))
  }
  r <- both_paths(build_dp)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("ag_batch_norm still falls back to closures", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # Documents a known gap rather than a desired behaviour: batch_norm is a
  # hybrid -- its normalisation is one monolithic closure (grad_out / std) and
  # its gamma/beta go through broadcast helpers that record no op either. Two
  # of its four tape nodes are therefore uncovered. When an emitter is added,
  # this expectation flips to "graph"; until then the fallback is what keeps
  # its gradients right.
  build <- function() {
    set.seed(83L)
    bn <- ag_batch_norm(4L)
    x  <- ag_tensor(matrix(runif(24, -1, 1), 4, 6))
    y  <- ag_tensor(matrix(runif(24, -1, 1), 4, 6))
    loss <- NULL
    with_grad_tape({
      loss <- ag_mse_loss(bn$forward(x), y)
    })
    list(loss = loss, params = list(bn$gamma, bn$beta))
  }

  r <- both_paths(build)

  expect_match(r$got_path, "^closures \\(")
  expect_lt(grad_maxdiff(r), 1e-6)
})

test_that("the graph path is off unless enabled", {
  # Default state must be the closure path: stage 1 covers three ops, so an
  # unflagged session has to behave exactly as before.
  old <- ag_backward_graph(FALSE)
  on.exit(ag_backward_graph(old %||% FALSE), add = TRUE)

  set.seed(14L)
  w <- ag_param(matrix(runif(4), 2, 2))
  x <- ag_tensor(matrix(c(1, 2), 1, 2))
  y <- ag_tensor(matrix(c(0, 1), 1, 2))
  with_grad_tape({
    loss <- ag_mse_loss(ag_matmul(x, w), y)
  })
  backward(loss)

  expect_identical(ag_backward_path(), "closures")
  expect_false(is.null(w$grad))
})


# ---------------------------------------------------------------------------
# PPO wave: exp, clamp (elemwise_mul), sub, log, sum/mean, and loss_const below
# the root. Closures are the reference, but closures and graph share the tape,
# so log, sub and the branching case are also checked against central finite
# differences of a plain-R loss -- a defect common to both paths shows there.
# ---------------------------------------------------------------------------

# Central differences of scalar f(m) w.r.t. every entry of m, in double on host.
fd_grad <- function(f, m, h = 1e-4) {
  g <- m
  for (i in seq_along(m)) {
    mp <- m; mp[i] <- mp[i] + h
    mm <- m; mm[i] <- mm[i] - h
    g[i] <- (f(mp) - f(mm)) / (2 * h)
  }
  g
}

# Graph-path gradient of one parameter, materialised.
graph_grad <- function(build, k = 1L) {
  old <- ag_backward_graph(TRUE)
  on.exit(ag_backward_graph(old %||% FALSE), add = TRUE)
  b <- build()
  backward(b$loss)
  list(g = .bwd_as_matrix(b$params[[k]]$grad), path = ag_backward_path())
}

test_that("graph backward matches closures for exp, clamp and log", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # clamp: entries exactly on both limits, where the mask must be 0 like the
  # closure's strict inequality.
  wc <- matrix(c(-0.5, 0.5, -0.2, 0.3, -0.9, 0.8, 0.5, -0.5, 0.1, 0.0,
                 0.7, -0.7, 0.25, -0.25, 0.45), 5, 3)
  cases <- list(
    list(nm = "exp",   f = function(w) ag_exp(w),
         w = function() matrix(runif(15, -1, 1), 5, 3)),
    list(nm = "clamp", f = function(w) ag_clamp(w, -0.5, 0.5),
         w = function() wc),
    list(nm = "log",   f = function(w) ag_log(w),
         w = function() matrix(runif(15, 0.5, 2), 5, 3)))

  for (cs in cases) {
    build <- function() {
      set.seed(61L)
      w <- ag_param(cs$w())
      y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
      loss <- NULL
      with_grad_tape({ loss <- ag_mse_loss(cs$f(w), y) })
      list(loss = loss, params = list(w))
    }
    r <- both_paths(build)
    expect_identical(r$got_path, "graph", info = cs$nm)
    expect_lt(grad_maxdiff(r), 1e-3)
  }
})

test_that("graph backward for log matches finite differences", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  set.seed(62L)
  w0 <- matrix(runif(15, 0.5, 2), 5, 3)
  y0 <- matrix(runif(15, -1, 1), 5, 3)
  build <- function() {
    w <- ag_param(w0)
    loss <- NULL
    with_grad_tape({ loss <- ag_mse_loss(ag_log(w), ag_tensor(y0)) })
    list(loss = loss, params = list(w))
  }
  r <- graph_grad(build)
  expect_identical(r$path, "graph")
  expect_equal(r$g, fd_grad(function(m) mean((log(m) - y0)^2), w0),
               tolerance = 1e-3, ignore_attr = TRUE)
})

test_that("graph backward for log at x <= 0 behaves like the closure", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # dx = g / x: Inf at 0, finite negative below. The forward is -Inf / NaN;
  # only the gradient rule is compared, so the loss is a plain sum.
  build <- function() {
    w <- ag_param(matrix(c(0, -1, 2, 0.5, -0.25, 4), 2, 3))
    loss <- NULL
    with_grad_tape({ loss <- ag_sum(ag_log(w)) })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build)
  expect_identical(r$got_path, "graph")
  expect_equal(.bwd_as_matrix(r$got_params[[1]]$grad),
               .bwd_as_matrix(r$ref_params[[1]]$grad), tolerance = 1e-5)
})

test_that("graph backward for sub matches closures and finite differences", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # Asymmetric 5x3: a wrong reduction axis or a lost sign cannot cancel out.
  set.seed(63L)
  x0 <- matrix(runif(15, -1, 1), 5, 3)
  y0 <- matrix(runif(15, -1, 1), 5, 3)
  for (bs in list(c(5L, 3L), c(1L, 3L), c(5L, 1L))) {
    b0 <- matrix(runif(prod(bs), -1, 1), bs[1L], bs[2L])
    build <- function() {
      a <- ag_param(x0)
      b <- ag_param(b0)
      loss <- NULL
      with_grad_tape({ loss <- ag_mse_loss(ag_sub(a, b), ag_tensor(y0)) })
      list(loss = loss, params = list(a, b))
    }
    info <- paste(bs, collapse = "x")
    r <- both_paths(build)
    expect_identical(r$got_path, "graph", info = info)
    expect_lt(grad_maxdiff(r), 1e-3)

    bexp <- function(m) matrix(m, 5L, 3L, byrow = bs[1L] == 1L)
    fd <- fd_grad(function(m) mean((x0 - bexp(m) - y0)^2), b0)
    expect_equal(.bwd_as_matrix(r$got_params[[2]]$grad), fd,
                 tolerance = 1e-3, ignore_attr = TRUE, info = info)
  }
})

test_that("graph backward matches closures for sum and mean on every dim", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  for (red in list(list(f = ag_sum,  nm = "sum"), list(f = ag_mean, nm = "mean"))) {
    for (d in list(NULL, 1L, 2L)) {
      ysh <- if (is.null(d)) c(1L, 1L) else if (d == 1L) c(5L, 1L) else c(1L, 3L)
      build <- function() {
        set.seed(64L)
        w <- ag_param(matrix(runif(15, -1, 1), 5, 3))
        y <- ag_tensor(matrix(runif(prod(ysh), -1, 1), ysh[1L], ysh[2L]))
        loss <- NULL
        with_grad_tape({ loss <- ag_mse_loss(red$f(w, dim = d), y) })
        list(loss = loss, params = list(w))
      }
      info <- paste(red$nm, if (is.null(d)) "all" else d)
      r <- both_paths(build)
      expect_identical(r$got_path, "graph", info = info)
      expect_lt(grad_maxdiff(r), 1e-3)
    }
  }
})

test_that("a loss below the tape root stays on the graph path", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # vf_loss as in PPO: scaled and added, so its loss_const is not the root.
  build <- function() {
    set.seed(65L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      h <- ag_matmul(x, w)
      loss <- ag_add(ag_scale(ag_mse_loss(h, y), 0.5),
                     ag_scale(ag_sum(ag_exp(h)), -0.01))
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("graph backward sums the gradients of a value used twice", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # h feeds exp and both operands of mul: three contributions to dh.
  set.seed(66L)
  w0 <- matrix(runif(12, -0.5, 0.5), 4, 3)
  x0 <- matrix(runif(20, -1, 1), 5, 4)
  y0 <- matrix(runif(15, -1, 1), 5, 3)
  build <- function() {
    w <- ag_param(w0)
    loss <- NULL
    with_grad_tape({
      h <- ag_matmul(ag_tensor(x0), w)
      loss <- ag_mse_loss(ag_add(ag_exp(h), ag_mul(h, h)), ag_tensor(y0))
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)

  fd <- fd_grad(function(m) { h <- x0 %*% m; mean((exp(h) + h * h - y0)^2) }, w0)
  expect_equal(.bwd_as_matrix(r$got_params[[1]]$grad), fd,
               tolerance = 1e-3, ignore_attr = TRUE)
})

test_that("nodes recorded after the loss do not touch its gradients", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # One node consumes the loss, one is unrelated to it but shares w.
  build <- function() {
    set.seed(67L)
    w <- ag_param(matrix(runif(12, -1, 1), 4, 3))
    x <- ag_tensor(matrix(runif(20, -1, 1), 5, 4))
    y <- ag_tensor(matrix(runif(15, -1, 1), 5, 3))
    loss <- NULL
    with_grad_tape({
      loss  <- ag_mse_loss(ag_matmul(x, w), y)
      after <- ag_scale(loss, 3)
      other <- ag_sum(ag_exp(ag_matmul(x, w)))
    })
    list(loss = loss, params = list(w))
  }
  r <- both_paths(build)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)
})

test_that("a non-scalar loss is refused by name", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)
  old <- ag_backward_graph(TRUE)
  on.exit(ag_backward_graph(old %||% FALSE), add = TRUE)

  w <- ag_param(matrix(runif(6), 2, 3))
  out <- NULL
  with_grad_tape({ out <- ag_exp(w) })
  try(backward(out), silent = TRUE)
  expect_identical(ag_backward_path(), "closures (loss is not a scalar)")
})

test_that("a PPO-shaped loss runs as one graph and matches closures", {
  skip_if_no_gpu()
  ag_device("gpu")
  on.exit(ag_device("cpu"), add = TRUE)

  # The loss of rltoolsR's ppo_update() with a 2-layer net: the tape is the
  # same op mix (softmax, clamp, log, sum(dim), exp, sub, relu, scale, mse
  # below the root). A fallback here is a silent speed loss, hence the path.
  B <- 256L; nobs <- 9L; nh <- 16L; na <- 9L
  set.seed(68L)
  W1 <- matrix(rnorm(nh * nobs, sd = 0.3), nh, nobs); b1 <- matrix(0, nh, 1)
  Wp <- matrix(rnorm(na * nh, sd = 0.3), na, nh);     bp <- matrix(0, na, 1)
  Wv <- matrix(rnorm(nh, sd = 0.3), 1, nh);           bv <- matrix(0, 1, 1)
  obs   <- matrix(runif(nobs * B, -1, 1), nobs, B)
  legal <- matrix(rbinom(na * B, 1, 0.7), na, B); legal[1, ] <- 1
  act   <- apply(legal, 2, function(l) which(l == 1)[1L])
  oh    <- matrix(0, na, B); oh[cbind(act, seq_len(B))] <- 1
  old0  <- matrix(log(runif(B, 0.1, 0.9)), 1)
  adv0  <- matrix(rnorm(B), 1)
  ret0  <- matrix(rnorm(B), 1)
  clip  <- 0.2

  build <- function() {
    ps <- lapply(list(W1, b1, Wp, bp, Wv, bv), ag_param)
    loss <- NULL
    with_grad_tape({
      h      <- ag_relu(ag_add(ag_matmul(ps[[1]], ag_tensor(obs)), ps[[2]]))
      logits <- ag_add(ag_matmul(ps[[3]], h), ps[[4]])
      value  <- ag_add(ag_matmul(ps[[5]], h), ps[[6]])
      p      <- ag_softmax(ag_add(logits, ag_tensor(-30 * (1 - legal))))
      logp   <- ag_log(ag_clamp(p, 1e-8, 1))
      logp_a <- ag_sum(ag_mul(logp, ag_tensor(oh)), dim = 2L)
      ratio  <- ag_exp(ag_sub(logp_a, ag_tensor(old0)))
      A      <- ag_tensor(adv0)
      s1     <- ag_mul(ratio, A)
      s2     <- ag_mul(ag_clamp(ratio, 1 - clip, 1 + clip), A)
      surr   <- ag_sub(s1, ag_relu(ag_sub(s1, s2)))
      pg     <- ag_scale(ag_sum(surr), -1 / B)
      vf     <- ag_mse_loss(value, ag_tensor(ret0))
      ent    <- ag_scale(ag_sum(ag_mul(p, logp)), -1 / B)
      loss   <- ag_add(ag_add(pg, ag_scale(vf, 0.5)), ag_scale(ent, -0.01))
    })
    list(loss = loss, params = ps)
  }

  r <- both_paths(build)
  expect_identical(r$got_path, "graph")
  expect_lt(grad_maxdiff(r), 1e-3)

  # Timing is reported, not asserted: it depends on the device.
  t_of <- function(on) {
    old <- ag_backward_graph(on)
    on.exit(ag_backward_graph(old %||% FALSE), add = TRUE)
    b <- build(); backward(b$loss)                      # warm-up
    system.time(for (i in 1:5) { b <- build(); backward(b$loss) })[["elapsed"]] / 5
  }
  tc <- t_of(FALSE); tg <- t_of(TRUE)
  message(sprintf("PPO loss build+backward: closures %.1f ms, graph %.1f ms (%.2fx)",
                  1000 * tc, 1000 * tg, tc / tg))
})

# ag_mul with a broadcast operand: [m,1], [1,n] and [1,1] against [m,n], the
# small one first and second, both requiring a gradient. Before the rule was
# emitted the whole tape fell back to closures ("closures (mul: broadcast)") --
# PPO's Gaussian policy multiplies [act,B] by exp(-log_std), log_std [act,1].
for (bc in list(c(4L, 1L), c(1L, 3L), c(1L, 1L))) for (small_first in c(FALSE, TRUE)) {
  test_that(sprintf("graph backward handles ag_mul broadcast [%d,%d], small %s",
                    bc[1], bc[2], if (small_first) "first" else "second"), {
    skip_if_no_gpu()
    ag_device("gpu")
    on.exit(ag_device("cpu"), add = TRUE)

    build <- function() {
      set.seed(31L)
      w <- ag_param(matrix(runif(15, -1, 1), 3, 5))
      s <- ag_param(matrix(runif(prod(bc), 0.5, 1.5), bc[1], bc[2]))
      x <- ag_tensor(matrix(runif(20, -1, 1), 4, 5))
      y <- ag_tensor(matrix(runif(12, -1, 1), 4, 3))
      loss <- NULL
      with_grad_tape({
        h <- ag_matmul(x, ag_transpose(w))                    # [4,3]
        p <- if (small_first) ag_mul(s, h) else ag_mul(h, s)
        loss <- ag_mse_loss(p, y)
      })
      list(loss = loss, params = list(w, s))
    }

    r <- both_paths(build)
    expect_identical(r$got_path, "graph")
    expect_identical(.bwd_dim(r$got_params[[2]]$grad), bc)
    expect_lt(grad_maxdiff(r), 1e-3)
  })
}

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.