Skip to content

Sum-layer precision option, and dense sum layers through cuBLAS - #62

Merged
liuanji merged 4 commits into
mainfrom
precision-option
Oct 22, 2026
Merged

liuanji merged 4 commits into
mainfrom
precision-option

Conversation

@liuanji

@liuanji liuanji commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Summary

1. A precision option for the sum layers (ebc503b3). TensorCircuit.precision (also juice.compile(..., precision = ...)) replaces force_use_bf16 / force_use_fp32:

mode forward products backward products log-likelihood error
"auto" (default) today's kernels: bf16 from batch 16 round-to-nearest TF32 ~4e-3
"tf32" round-to-nearest TF32, never bf16 round-to-nearest TF32 ~5e-4
"fp32" fp32-level (tf32x3 in the Triton tlmm kernel) fp32-level (tf32x3) ~1e-6

The mode can be changed at any time, and a recorded CUDA graph is keyed on it. Batches below 16 run fp32 kernels in every mode, as before.

2. Dense sum layers through cuBLAS (79b76e7e). A large dense layer exponentiates its children, multiplies on cuBLAS using views of the parameters, and takes the log. "Large" means block size ≥ 1024 and children × batch ≥ 2^19, for LL without tempering.

  • Layers whose blocks share their children (HMMs) exponentiate them once for all blocks.
  • Layers with evenly strided child groups (HCLT) run as batched GEMMs, with scratch capped at 64 MB.
  • Any other layout keeps the kernels.

The products are TF32 in "auto" and "tf32", and exact fp32 in "fp32". The gate is fixed, never chosen by timing, because the two paths differ numerically.

3. "fp32"'s backward on tensor cores (18f7192f). In "fp32" mode the element- and parameter-flow kernels used to drop tl.dot for broadcast sums on CUDA cores, as force_use_fp32 always did. They now keep the dots and run them in three TF32 passes (input_precision = "tf32x3").

  • On one HMM layer, against float64, the flows are 5e-7 from exact. The round-to-nearest TF32 of "auto" and "tf32" is 5e-5 (element flows) and 3e-4 (parameter flows).
  • The other modes launch the kernels as before.
  • The fp16/TF32 CUDA backward kernels stay off in "fp32".

Breaking change

force_use_bf16 = True / force_use_fp32 = True now raise a TypeError that names the replacement, in TensorCircuit.forward / backward and in SumLayer.forward / backward. Passing False (their old default) is still accepted and ignored.

Results

Whole-circuit timings against main, "auto", RTX PRO 6000. Each figure is the median over processes: two GPUs on different CPU sockets with the arms swapped, plus alternating one-at-a-time runs.

workload batch forward forward + backward
HMM, 4096 latents, 32 positions 128 ×2.25 ×1.31
HMM, 4096 latents, 32 positions 512 ×1.57 ×1.12
HMM, 1024 latents, 64 positions 512 ×1.10 ×1.02
HCLT, 1024 latents, 768 variables, 512 categories 512 ×1.20 ×1.05
layers the gate does not take (PD, smaller batches) ×1.00 ×1.00 (within ~1%)
  • Accuracy: dense layers now use TF32 instead of bf16 products. On a 1024-latent HMM layer at batch 128, the log-likelihood error drops from ~4e-3 to ~5e-4.

  • Memory: on HCLT at batch 512 the dense path needs 64 MB of scratch, against 3 MB for the kernels, with 6.7 GB resident.

  • "fp32" speed: forward+backward is now ×0.64–0.98 of "auto", from 2–50× slower before the third commit:

    workload batch "auto" "fp32", broadcast-sum backward "fp32", tf32x3 backward
    HMM, 4096 latents, 32 positions 128 13.9 ms 299.6 ms 17.9 ms
    HMM, 4096 latents, 32 positions 512 32.1 ms 1608.9 ms 49.9 ms
    HMM, 1024 latents, 64 positions 512 10.2 ms 153.1 ms 12.3 ms
    HCLT, 1024 latents, 768 variables 512 48.1 ms 1579.4 ms 64.3 ms

Tests

Full suite: 1301 passed, 26 skipped.

  • tests/layer/sum_layer_precision_test.py:
    • each mode against a float64 forward, on the Triton path, on one-group dense layers and on batched three-group dense layers;
    • the per-group column-maximum edge cases;
    • the backward's element and parameter flows in each mode against float64, with "fp32" passing tf32x3 to both flow kernels and the other modes passing nothing. Downgrading either kernel's dots to TF32 fails it;
    • the gate, and the plans the dense path makes of made-up layouts;
    • the removed flags;
    • unknown modes.
    • Each of nine mutations of the dense path is caught.
  • tests/model/precision_option_test.py:
    • compiling with and setting the mode;
    • every sum layer receiving it in both passes;
    • the removed flags at the circuit;
    • CUDA graphs keyed on it.
  • The tests that set force_use_fp32 now set pc.precision.

…se_bf16 / force_use_fp32

TensorCircuit.precision (and juice.compile(..., precision = ...)) sets
the precision of the sum layers' products in the forward and the
backward pass:

* "auto" (the default) keeps today's kernels: bf16 products in the
  block-sparse kernels from batch 16, about 4e-3 in a log-likelihood;
* "tf32" never uses bf16: round-to-nearest TF32 products, about 5e-4;
* "fp32" gives fp32-level products in the forward (tf32x3 in the Triton
  tlmm kernel, about 1e-6) and computes the flows without tensor cores
  in the backward, as force_use_fp32 did.

Batches below 16 run fp32 kernels in every mode, as before. The mode can
be changed at any time. It reaches every sum layer in the forward, the
backward and the partition-gradient helper, and a recorded CUDA graph is
keyed on it.

force_use_bf16 and force_use_fp32 are removed. Passed as False (their old
default) they are ignored; passed as True they raise a TypeError naming
the replacement, instead of computing in a precision the caller did not
ask for. The tests that set force_use_fp32 now set pc.precision.

The tlmm kernel gets a constexpr fp32_dot (default False). It is passed
only in "fp32" mode, so "auto" and "tf32" launch the kernel exactly as
before.

New tests: tests/layer/sum_layer_precision_test.py (each mode against a
float64 forward, the removed flags at the layer, an unknown mode) and
tests/model/precision_option_test.py (compiling with and setting the
mode, every sum layer receiving it in both passes, the removed flags at
the circuit, CUDA graphs keyed on it).
A sum layer whose node blocks are dense -- children a contiguous run of
element rows, parameters strided by the block size -- is one matrix
product per block. For LL without tempering, at block size >= 1024,
batch >= 16 and children x batch >= 2^19, such a layer now exponentiates
its children, multiplies on cuBLAS using views of the parameters (no
copy) and takes the log. Fused Triton kernels compute the column maximum,
the exp and the log (layer/kernels/sum_forward_dense.py). The products
are TF32 in "auto" and "tf32" and exact fp32 in "fp32". The gate is fixed
and never chosen by timing, because the two paths differ numerically.

Node blocks that share their children (an HMM layer) exponentiate them
once for all blocks. Blocks that each have their own children, evenly
strided in children, node rows and parameters (HCLT: 2-127 groups per
layer), run as batched GEMMs over [groups, ...] views, with as many
groups per GEMM as a 64 MB scratch buffer holds. Any other layout keeps
the kernels. Looping over HCLT-1024's 452 groups one at a time ran its
forward at 0.71x the kernels' speed.

Whole-circuit forward / forward+backward against the parent commit,
"auto", on an RTX PRO 6000 (median over processes):

  HMM, 4096 latents, 32 positions, batch 128     x2.25 / x1.31
  HMM, 4096 latents, 32 positions, batch 512     x1.57 / x1.12
  HMM, 1024 latents, 64 positions, batch 512     x1.10 / x1.02
  HCLT, 1024 latents, 768 variables, batch 512   x1.20 / x1.05

Where the gate takes no layer (smaller batches, PD, HMM 1024 up to batch
256), times are unchanged within about 1%. The dense layers also get TF32
instead of bf16 products: on a 1024-latent HMM layer at batch 128 the
log-likelihood error drops from about 4e-3 to 5e-4. On HCLT at batch 512
the path needs 64 MB of scratch, against 3 MB for the kernels, with
6.7 GB resident.

New tests in tests/layer/sum_layer_precision_test.py:
* each mode against a float64 forward, on a one-group layer and on a
  three-group batched layer;
* per-group column maxima: a -inf group, a group 200 below the others,
  and a maximum in a later row tile;
* batched runs of one and two groups;
* the gate;
* the plans _dense_blocks makes of made-up layouts;
* block-sparse layers staying on the kernels.
Each of nine mutations of the new code is caught.
In "fp32" mode the element- and parameter-flow kernels dropped tl.dot
for broadcast sums on CUDA cores, as force_use_fp32 always did. The
backward of a 4096-latent HMM at batch 512 took 1.6 s, against 32 ms in
"auto". The kernels now keep the dots and run them in three TF32 passes
(input_precision = "tf32x3"): fp32-level, on tensor cores. On a
64-latent HMM layer at batch 48 the flows are 5e-7 from float64,
relative to the largest flow. With the round-to-nearest TF32 dots of
"auto" and "tf32" they are 5e-5 (element flows) and 3e-4 (parameter
flows).

The kernels take a constexpr DOT_TF32X3 (default False), passed only in
"fp32" mode, so "auto" and "tf32" launch them as before. The autotune
keys include it. PYJUICE_PAR_DOT_IEEE still takes precedence in the
parameter flows. The CUDA element- and parameter-flow kernels (fp16 /
TF32 dots) stay off in "fp32" mode, and tiles below 16 still sum
without dots.

Whole-circuit forward + backward in "fp32", before / after:

  HMM, 4096 latents, 32 positions, batch 512     1608.9 / 49.9 ms
  HMM, 4096 latents, 32 positions, batch 128      299.6 / 17.9 ms
  HMM, 1024 latents, 64 positions, batch 512      153.1 / 12.3 ms
  HCLT, 1024 latents, 768 variables, batch 512   1579.4 / 64.3 ms
  PD, 256 latents, batch 512                      105.2 / 64.2 ms

"fp32" now runs at 0.64-0.98x the speed of "auto"; before this change it
was 2-50x slower.

New test: one layer's element and parameter flows against float64 in
each mode, with "fp32" passing tf32x3 to both flow kernels and the other
modes passing nothing. Downgrading either kernel's tf32x3 dots to TF32
fails it.
… is measured once per launch shape and shared process-wide -- two identical models no longer take different arms and differ by ~1e-6

SumLayer's CUDA (CuTe) tlmm forward fast path timed Triton against its valid CUDA tile configs per layer and kept
the winner on the layer (`_cached_fw_cuda_choice`). The arms are numerically equivalent but not bit-identical
(~1e-6 apart), and with the winner decided by a timing close to the margin, two structurally identical models
could take different arms. precision_option_test's test_recorded_graphs_are_keyed_on_it (a CUDA-graph HMM against
its eager twin, bit for bit) failed in every full-suite run, 3 of 3 on croissant and brezel, while passing when
run alone: the eager twin's first sum layer on Triton, the graph model's on CUDA config 1, 9.5e-7 apart.

The choice now goes through `autotune.choose`, new beside `pick`: measured once per key by `best_of` (Triton the
reference, kept unless a CUDA config clears MARGIN, as before) and cached in the autotuner's process-wide cache,
under ("sum_fw_tlmm_cuda", num_edges) + the forward's shape key, as the small-batch gate's choice already is.
Unlike `pick` it measures whatever ENABLED says, as this gate always has; during a CUDA graph capture an
unmeasured shape takes Triton, uncached. What changes is only which layers share a measurement: identical shapes
share one, and a model measures each shape once instead of each layer. No kernel changes.

Tests: sum_layer_tlmm_choice_test -- with a measurement that names a different arm every time it is asked, the
second of two identical HMMs measures nothing and matches the first bit for bit; it fails without this fix.
The full suite passes on croissant with four workers on one GPU, where both failed the CUDA-graph test before:
1298 tests on this branch, and 1298 merged into main.
@liuanji
liuanji merged commit 5b99e7f into main Oct 22, 2026
1 check passed
liuanji added a commit that referenced this pull request Oct 22, 2026
Brings in #62: the sum layers' precision option, the cuBLAS dense path, the tf32x3 backward, and the shared choice of
the tlmm fast path. #63, the z-correction default, is on this branch already as 1ed9d48.

Conflicts: tensorcircuit.py and block_scale.py, both split into modules on this branch. Both take the branch's side.
tensorcircuit.py adds #62's import of PRECISIONS and _pop_removed_precision_flags, and #62's change to the element-flow
helper goes to model/inspection.py, where that helper now lives. block_scale.py's split modules already carry #63.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant